diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 00000000..4f3b76b0 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 00000000..1cc09f22 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@0.3.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "linked": [], + "access": "public", + "baseBranch": "master" +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..95dda872 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,47 @@ +module.exports = { + root: true, + parser: 'babel-eslint', + env: { + browser: true, + es6: true, + node: true, + jest: true + }, + plugins: ['jest'], + rules: { + 'import/no-unresolved': 'off', + 'no-console': 'off', + 'no-case-declarations': 'off', + 'no-prototype-builtins': 'off', + 'no-unsafe-finally': 'off', + 'import/order': [ + 'error', + { 'newlines-between': 'always-and-inside-groups' } + ], + 'react/prop-types': 'off', + 'react/display-name': 'off', + 'react/no-children-prop': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'off' + }, + extends: [ + 'react-app', + 'eslint:recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:react/recommended', + 'plugin:jest/recommended' + ], + settings: { + react: { + version: 'detect' + }, + + 'import/extensions': ['.js', '.jsx'], + 'import/resolver': { + node: { + extensions: ['.js', '.jsx'] + } + } + } +} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..6428c9d0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build-and-lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@master + + - name: Set up Node.js 12.x + uses: actions/setup-node@master + with: + node-version: 12.x + + - name: Install + run: yarn + + - name: Lint + run: yarn lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a4a47333 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + push: + branches: + - master + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@master + with: + fetch-depth: 0 + + - name: Setup Node.js 12.x + uses: actions/setup-node@master + with: + node-version: 12.x + + - name: Install + run: yarn + + - name: Create release pull request + uses: changesets/action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..ba4d20fc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test + +on: [push, pull_request] + +jobs: + build-and-test: + name: 'Build on ${{ matrix.platform }} with node.js ${{ matrix.node-version }}' + strategy: + matrix: + platform: [ubuntu-latest, windows-latest, macos-latest] + node-version: [12] + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout + uses: actions/checkout@master + + - name: Set up Node.js + uses: actions/setup-node@master + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: yarn + + - name: Test + run: yarn test diff --git a/.gitignore b/.gitignore index ab322b06..c8e07e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist public gatsby *.swp +yarn-error.log diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..9cf94950 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..1be4fdf1 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = { + presets: ['@babel/preset-env', '@babel/preset-react'], + plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-proposal-export-namespace-from' + ] +} diff --git a/code-of-conduct.md b/code-of-conduct.md new file mode 100644 index 00000000..c1ec7aec --- /dev/null +++ b/code-of-conduct.md @@ -0,0 +1,92 @@ +# Code of conduct + +## Our pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our collective +and our community a harassment-free experience for everyone, regardless of age, +body size, disability, ethnicity, sex characteristics, gender identity and +expression, level of experience, education, socio-economic status, nationality, +personal appearance, race, religion, or sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others’ private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our responsibilities + +Maintainers are responsible for clarifying the standards of acceptable behavior +and are expected to take appropriate and fair corrective action in response to +any instances of unacceptable behavior. + +Maintainers have the right and responsibility to edit, delete, hide, or lock +code, comments, commits, edits, issues, posts, pull requests, and other +contributions that are not aligned to this code of conduct, or to ban +temporarily or permanently any contributor for other behaviors that they deem +inappropriate, threatening, offensive, or harmful. + +## Scope + +This code of conduct applies both within collective spaces and in public spaces +when an individual is representing the collective or its community. +Examples of representing a collective or community include using an official +collective email address, posting via an official social media account, or +acting as an appointed representative at an online or offline event. +Representation of the collective may be further defined and clarified by +maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to `johnotander+blocks@gmail.com`. +All complaints will be reviewed and investigated and will result in a response +that is deemed necessary and appropriate to the circumstances. +The moderation team is obligated to maintain confidentiality with regard to the +reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Maintainers who do not follow or enforce the code of conduct in good faith may +face temporary or permanent repercussions as determined by other members of the +collective leadership. + +In cases where community members transgress against the values in the code of +conduct, members of the moderation team will use a three-strike warning system, +where the aggressor will be warned twice before they are permanently excluded +from our community spaces. +This code applies to GitHub, Spectrum, Twitter, and any other space that this +community uses for communication. +For interactions between community members outside of this space, the code also +applies if the interactions are reported and deemed to be interfering with +community members safely working on the collective together. +Conversations between moderators (when they are more serious than discussing +simple warnings) will occur in private repositories or through email, to ensure +both anonymity for reporters and the safety of the moderators. + +## Attribution + +This code of conduct is adapted from the [Contributor Covenant][homepage], +version 1.4, available at + +For answers to common questions about this code of conduct, see + + +[homepage]: https://www.contributor-covenant.org + diff --git a/contributing.md b/contributing.md new file mode 100644 index 00000000..0133bb92 --- /dev/null +++ b/contributing.md @@ -0,0 +1,57 @@ +# Contributing + +> This project has a [Code of Conduct](./code-of-conduct.md). +> By interacting with this repository, organization, or community you agree to +> abide by its terms. + +Hi! πŸ‘‹ +We’re excited that you’re interested in contributing! +Take a moment to read the following guidelines. +And thanks for contributing to **Blocks**! πŸ‘πŸ‘Œβœ¨ + +## Getting started + +Fork the repo to your personal Github account. + +Then clone the repo and install the dependencies. + +```sh +git clone https://github.com/blocks/blocks +cd blocks +yarn +``` + +Then, run the docs site: + +```sh +yarn start +``` + +The docs site includes a demo page which is a great way to get +started making changes to the editor itself. + +Docs pages themselves are located in `src/pages`. All types of +docs contributions are very welcome! + +## How to contribute? + +Before undergoing a substantial to docs or the codebase, please +open up an issue to make sure it's a desired change. We don't want +you to put in lots of time for something that isn't part of this +project's goals. + +We're working on formalizing the roadmap for **Blocks**, to make +the path to contributing a little bit clearer. + +For now, good places to look at are the +[issues](https://github.com/blocks/blocks/issues) and the +[roadmap](https://github.com/orgs/blocks/projects/1). + +If you're keen on the design side of things there's a +[Figma project](https://www.figma.com/file/7CeIPXhJJudZvq5ofmfx5p/Blocks-UI). + +Reporting bugs that have not been issued yet also helps! + +## Related + +- [How it works](https://blocks-ui.com/docs/advanced/how-it-works/): Summary of the editor's architecture. diff --git a/gatsby-config.js b/gatsby-config.js index 71c60248..52fdf0df 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -11,17 +11,12 @@ module.exports = { 'gatsby-plugin-theme-ui', 'gatsby-plugin-mdx', 'gatsby-plugin-catch-links', + 'gatsby-plugin-blocks', { resolve: 'gatsby-source-filesystem', options: { name: 'blocks', - path: 'packages/blocks/src' - } - }, - { - resolve: 'gatsby-plugin-compile-es6-packages', - options: { - modules: ['blocks-ui'] + path: 'packages/react/src' } }, { diff --git a/gatsby-node.js b/gatsby-node.js index 3bb24dc9..1ca03c27 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -1,5 +1,5 @@ -const transforms = require('blocks-ui/dist/transforms') -const queries = require('blocks-ui/dist/queries') +const { transforms, queries } = require('blocks-ui') + const BlockTemplate = require.resolve('./src/templates/block') const toComponentName = name => name.charAt(0).toUpperCase() + name.slice(1) diff --git a/lerna.json b/lerna.json deleted file mode 100644 index 8b742dc7..00000000 --- a/lerna.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "packages": [ - "packages/*" - ], - "version": "0.0.38" -} diff --git a/license b/license new file mode 100644 index 00000000..b59add87 --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 John Otander + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/package.json b/package.json index ceac0cde..daff5c06 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,29 @@ "private": true, "name": "blocks", "scripts": { - "start": "yarn docs", - "build": "gatsby build", + "start": "preconstruct dev && yarn docs", + "build": "yarn gatsby build", + "build:packages": "preconstruct build", + "changeset": "changeset", "clean": "gatsby clean", - "deps-update": "ncu -u && lerna exec \"ncu -u\"", "docs": "gatsby develop", - "docs-build": "gatsby build", "docs-deploy": "now switch blocks-ui && now && now alias $(pbpaste) blocks-ui.com", - "prepare": "lerna run prepare", - "publish": "lerna publish --force-publish=\"*\"" + "lint": "eslint .", + "postinstall": "preconstruct dev", + "release": "yarn build:packages && changeset publish", + "test": "jest", + "test:deps": "manypkg check" }, "workspaces": [ "packages/*" ], + "preconstruct": { + "packages": [ + "packages/blocks-ui", + "packages/react", + "packages/property-controls" + ] + }, "husky": { "hooks": { "pre-commit": "lint-staged" @@ -22,34 +32,61 @@ }, "lint-staged": { "*.js": [ - "prettier --parser flow --single-quote --no-semi --write", - "git add" + "prettier --write" ] }, + "jest": { + "testPathIgnorePatterns": [ + "/.cache/", + "/packages/mdx/" + ], + "testEnvironment": "node" + }, "dependencies": { - "@emotion/core": "10.0.22", - "@mdx-js/mdx": "1.5.1", - "@mdx-js/react": "1.5.1", - "@theme-ui/components": "0.2.49", - "@theme-ui/presets": "0.2.44", - "gatsby-plugin-catch-links": "2.1.17", - "gatsby-plugin-compile-es6-packages": "2.1.0", - "gatsby-plugin-fathom": "1.1.0", - "gatsby-plugin-manifest": "2.2.29", - "gatsby-plugin-mdx": "1.0.57", - "gatsby-plugin-react-helmet": "3.1.15", - "gatsby-plugin-theme-ui": "0.2.43", - "gatsby-source-filesystem": "2.1.38", - "react": "16.12.0", - "react-dom": "16.12.0", - "react-helmet": "5.2.1", - "theme-ui": "0.2.49" + "@babel/plugin-proposal-export-namespace-from": "7.10.1", + "@babel/preset-env": "7.10.3", + "@babel/preset-react": "7.10.1", + "@changesets/cli": "2.9.1", + "@emotion/core": "10.0.28", + "@manypkg/cli": "0.14.0", + "@mdx-js/mdx": "1.6.6", + "@mdx-js/react": "1.6.6", + "@preconstruct/cli": "1.1.14", + "@theme-ui/components": "0.3.1", + "@theme-ui/presets": "0.3.0", + "@theme-ui/prism": "0.3.0", + "babel-eslint": "10.1.0", + "eslint": "7.3.1", + "eslint-config-react-app": "5.2.1", + "eslint-plugin-flowtype": "5.1.3", + "eslint-plugin-import": "2.21.2", + "eslint-plugin-jest": "23.17.1", + "eslint-plugin-jsx-a11y": "6.3.1", + "eslint-plugin-react": "7.20.0", + "eslint-plugin-react-hooks": "4.0.4", + "gatsby": "2.23.11", + "gatsby-plugin-blocks": "0.0.80", + "gatsby-plugin-catch-links": "2.3.7", + "gatsby-plugin-fathom": "1.3.0", + "gatsby-plugin-manifest": "2.4.14", + "gatsby-plugin-mdx": "1.2.18", + "gatsby-plugin-react-helmet": "3.3.6", + "gatsby-plugin-theme-ui": "0.3.0", + "gatsby-source-filesystem": "2.3.14", + "husky": "4.2.5", + "jest": "26.1.0", + "lint-staged": "10.2.11", + "prettier": "2.0.5", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-helmet": "6.1.0", + "theme-ui": "0.3.1" }, - "devDependencies": { - "gatsby": "2.18.3", - "husky": "3.1.0", - "lerna": "3.19.0", - "lint-staged": "9.4.3", - "prettier": "1.19.1" + "resolutions": { + "@emotion/core": "10.0.28", + "@mdx-js/react": "1.6.6", + "react": "16.13.1", + "react-dom": "16.13.1", + "theme-ui": "0.3.1" } } diff --git a/packages/blocks-ui/.babelrc b/packages/blocks-ui/.babelrc deleted file mode 100644 index 2b7bafa5..00000000 --- a/packages/blocks-ui/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["@babel/preset-env", "@babel/preset-react"] -} diff --git a/packages/blocks-ui/CHANGELOG.md b/packages/blocks-ui/CHANGELOG.md new file mode 100644 index 00000000..cb7aaf3c --- /dev/null +++ b/packages/blocks-ui/CHANGELOG.md @@ -0,0 +1,189 @@ +# blocks-ui + +## 0.0.79 + +### Patch Changes + +- c1875b8: Prop label screen reader support +- a9ad043: Bump deps +- Updated dependencies [a9ad043] + - property-controls@0.0.60 + - @blocks/react@0.0.64 + +## 0.0.78 + +### Patch Changes + +- 1411594: Update prettier parser import for v2 + +## 0.0.77 + +### Patch Changes + +- 776b7c3: Bump deps, add resolutions for context libs +- Updated dependencies [776b7c3] + - property-controls@0.0.59 + - @blocks/react@0.0.63 + +## 0.0.76 + +### Patch Changes + +- c1eb7b0: Remove null literal from showing up in the editor +- 13a4e86: Misc UI updates + +## 0.0.75 + +### Patch Changes + +- 9cf9ead: Bump and pin deps +- Updated dependencies [9cf9ead] + - @blocks/react@0.0.62 + +## 0.0.74 + +### Patch Changes + +- 2704bd5: Bump theme ui preset version + +## 0.0.73 + +### Patch Changes + +- 4e32769: Add min and max value for font size based on theme +- 0644349: Restrict font sizes to theme-based values. Also fixed some styles in space editor. +- dc0425f: Disable dropping for components tab + +## 0.0.72 + +### Patch Changes + +- c5bd0d2: Added a slider for editing margin/padding with theme values +- b136932: Fix z-index for typograph dropdown + +## 0.0.71 + +### Patch Changes + +- db0398e: Improve prop apply plugin + +## 0.0.70 + +### Patch Changes + +- 475bb3f: Add a fancy loader +- 1ee0279: Fix lint warnings +- e601127: Better handle falsey values in sx prop plugin +- Updated dependencies [1ee0279] + - @blocks/react@0.0.61 + +## 0.0.69 + +### Patch Changes + +- 6c869e3: Lazy loaded mode components +- 6c869e3: Forked @theme-ui/components into blocks-ui +- Updated dependencies [6c869e3] + - @blocks/react@0.0.60 + +## 0.0.68 + +### Patch Changes + +- 6042b54: Use Monaco for the canvas code editor. + +## 0.0.67 + +### Patch Changes + +- e5fb954: Added a new `canvas` context which will provide setters/getters for setting the canvas size. This fixes a bug when switching from viewport mode to canvas mode. +- e5fb954: Replace @emotion/weak-memoize with useMemo + +## 0.0.66 + +### Patch Changes + +- 2f42c5e: Implement code editing + +## 0.0.65 + +### Patch Changes + +- 782d176: Resize blocks when dragging over the canvas +- 782d176: Update editor layout grid +- 782d176: Add title to demo page +- 782d176: Remove pointer events from block listing preview + +## 0.0.64 + +### Patch Changes + +- 05b2c67: Add a hoverstate to elements that's synced with the layers panel +- Updated dependencies [05b2c67] + - @blocks/react@0.0.59 + +## 0.0.63 + +### Patch Changes + +- edfc06c: Fix clone element bug with child nodes + +## 0.0.62 + +### Patch Changes + +- b9427db: Fix tab styles for Safari + +## 0.0.61 + +### Patch Changes + +- dcc372b: Make the entire canvas droppable + +## 0.0.60 + +### Patch Changes + +- 09b0ea5: Rewrite editor internals, begin building gatsby-plugin-blocks + +## 0.0.59 + +### Patch Changes + +- 746a4f9: Add a layers panel +- Updated dependencies [746a4f9] + - property-controls@0.0.58 + - @blocks/react@0.0.58 + +## 0.0.58 + +### Patch Changes + +- 9c967f0: Updated tabs to fix inconsistencies in Safari + +## 0.0.57 + +### Patch Changes + +- ea28d49: Add eslint and changes based on linting +- Updated dependencies [ea28d49] + - property-controls@0.0.57 + - @blocks/react@0.0.57 + +## 0.0.56 + +### Patch Changes + +- 92a203a: Rename the editor file to be lower case +- Updated dependencies [92a203a] + - property-controls@0.0.56 + - @blocks/react@0.0.56 + +## 0.0.55 + +### Patch Changes + +- 7f40455: New publish tooling +- Updated dependencies [7f40455] + - property-controls@0.0.55 + - @blocks/react@0.0.55 diff --git a/packages/blocks-ui/package.json b/packages/blocks-ui/package.json index f8e43d7c..dc4c21cc 100644 --- a/packages/blocks-ui/package.json +++ b/packages/blocks-ui/package.json @@ -1,33 +1,52 @@ { "name": "blocks-ui", - "version": "0.0.38", - "main": "dist/index.js", + "main": "dist/blocks-ui.cjs.js", + "module": "dist/blocks-ui.esm.js", + "version": "0.0.79", "license": "MIT", "repository": "blocks/blocks", - "scripts": { - "prepare": "babel src -d dist && cp src/recipes.txt dist/recipes.txt", - "watch": "babel src -d dist --watch" - }, "dependencies": { - "@babel/plugin-syntax-jsx": "^7.2.0", - "@babel/plugin-transform-react-jsx": "^7.7.0", - "@babel/standalone": "^7.7.3", - "@babel/template": "^7.7.0", - "@reach/tabs": "^0.6.1", - "@theme-ui/components": "^0.2.49", - "@theme-ui/editor": "^0.2.49", - "@theme-ui/presets": "^0.2.44", - "prettier": "^1.19.1", - "property-controls": "^0.0.38", - "react-beautiful-dnd": "^12.1.1", - "react-feather": "^2.0.3", - "theme-ui": "^0.2.49" + "@babel/helper-plugin-utils": "^7.10.3", + "@babel/plugin-syntax-jsx": "^7.10.1", + "@babel/plugin-transform-react-jsx": "^7.10.3", + "@babel/standalone": "^7.10.3", + "@babel/template": "^7.10.3", + "@babel/traverse": "^7.10.3", + "@babel/types": "^7.10.3", + "@blocks/react": "^0.0.64", + "@blocks/react-beautiful-dnd": "^12.2.1", + "@emotion/cache": "^10.0.29", + "@emotion/core": "10.0.28", + "@mdx-js/react": "1.6.6", + "@monaco-editor/react": "^3.3.2", + "@reach/tabs": "^0.10.5", + "@theme-ui/components": "0.3.1", + "@theme-ui/presets": "0.3.0", + "@theme-ui/style-guide": "^0.3.0", + "chroma-js": "^2.1.0", + "interpolate-range": "^2.1.0", + "lodash.merge": "^4.6.2", + "prettier": "2.0.5", + "property-controls": "^0.0.60", + "react-color": "^2.18.1", + "react-feather": "^2.0.8", + "react-loadable": "^5.5.0", + "react-merge-refs": "^1.0.0", + "reakit": "^1.1.1", + "theme-ui": "0.3.1", + "tinycolor2": "^1.4.1" }, "devDependencies": { - "@babel/cli": "7.7.4", - "@babel/core": "7.7.4", - "@babel/preset-env": "7.7.4", - "@babel/preset-react": "7.7.4", - "jest": "24.9.0" + "@babel/cli": "7.10.3", + "@babel/core": "7.10.3", + "@babel/preset-env": "7.10.3", + "@babel/preset-react": "7.10.1", + "jest": "26.1.0", + "react": "16.13.1", + "react-dom": "16.13.1" + }, + "peerDependencies": { + "react": "^16.13.1", + "react-dom": "^16.13.1" } } diff --git a/packages/blocks-ui/src/babel-plugins/__snapshots__/get-exported-elements.test.js.snap b/packages/blocks-ui/src/babel-plugins/__snapshots__/get-exported-elements.test.js.snap new file mode 100644 index 00000000..e5bcc6a1 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/__snapshots__/get-exported-elements.test.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should generate a tree of JSX elements from an AST 1`] = ` +Object { + "HeaderBasic": Node { + "children": Array [], + "closingElement": Node { + "end": 44, + "innerComments": undefined, + "leadingComments": undefined, + "loc": SourceLocation { + "end": Position { + "column": 44, + "line": 1, + }, + "start": Position { + "column": 38, + "line": 1, + }, + }, + "name": Node { + "end": 43, + "innerComments": undefined, + "leadingComments": undefined, + "loc": SourceLocation { + "end": Position { + "column": 43, + "line": 1, + }, + "start": Position { + "column": 40, + "line": 1, + }, + }, + "name": "div", + "start": 40, + "trailingComments": undefined, + "type": "JSXIdentifier", + }, + "start": 38, + "trailingComments": undefined, + "type": "JSXClosingElement", + }, + "end": 44, + "innerComments": undefined, + "leadingComments": undefined, + "loc": SourceLocation { + "end": Position { + "column": 44, + "line": 1, + }, + "start": Position { + "column": 33, + "line": 1, + }, + }, + "openingElement": Node { + "attributes": Array [], + "end": 38, + "innerComments": undefined, + "leadingComments": undefined, + "loc": SourceLocation { + "end": Position { + "column": 38, + "line": 1, + }, + "start": Position { + "column": 33, + "line": 1, + }, + }, + "name": Node { + "end": 37, + "innerComments": undefined, + "leadingComments": undefined, + "loc": SourceLocation { + "end": Position { + "column": 37, + "line": 1, + }, + "start": Position { + "column": 34, + "line": 1, + }, + }, + "name": "div", + "start": 34, + "trailingComments": undefined, + "type": "JSXIdentifier", + }, + "selfClosing": false, + "start": 33, + "trailingComments": undefined, + "type": "JSXOpeningElement", + }, + "start": 33, + "trailingComments": undefined, + "type": "JSXElement", + }, +} +`; diff --git a/packages/blocks-ui/src/babel-plugins/add-block-imports.js b/packages/blocks-ui/src/babel-plugins/add-block-imports.js new file mode 100644 index 00000000..e62fc634 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/add-block-imports.js @@ -0,0 +1,36 @@ +import template from '@babel/template' + +import { uniq } from '../util' + +// TODO: Make this configurable for other "blocks packs" +const BLOCKS_IMPORT_SOURCE = '@blocks/react' + +const isBlocksImport = node => + node.source && node.source.extra.rawValue === BLOCKS_IMPORT_SOURCE + +export default (_api, { blocks = [] }) => { + return { + visitor: { + ImportDeclaration(path) { + if (!isBlocksImport(path.node)) { + return + } + + const importNames = blocks.map(block => block.name) + const allImports = uniq(['Blocks', ...importNames]) + + if (blocks.length) { + const imports = uniq(allImports).join(', ') + const importAst = template.ast( + `import {${imports}} from "${BLOCKS_IMPORT_SOURCE}"` + ) + + path.replaceWith(importAst) + path.skip() + } else { + path.remove() + } + } + } + } +} diff --git a/packages/blocks-ui/src/babel-plugins/add-block-imports.test.js b/packages/blocks-ui/src/babel-plugins/add-block-imports.test.js new file mode 100644 index 00000000..140d81e6 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/add-block-imports.test.js @@ -0,0 +1,22 @@ +import { testPlugin } from './test-util' +import plugin from './add-block-imports' + +test('add additional blocks named imports', () => { + const result = testPlugin( + plugin, + 'import React from "react"\nimport { Blocks } from "@blocks/react"', + { + blocks: [{ name: 'HeaderBasic' }] + } + ) + + expect(result).toEqual( + `import React from "react";\nimport { Blocks, HeaderBasic } from "@blocks/react"` + ) +}) + +test('should not update non block ImportDeclaration', () => { + const result = testPlugin(plugin, 'import React from "react"') + + expect(result).toEqual(`import React from "react"`) +}) diff --git a/packages/blocks-ui/src/babel-plugins/add-tuid-prop.js b/packages/blocks-ui/src/babel-plugins/add-tuid-prop.js index d367fdc6..7533429e 100644 --- a/packages/blocks-ui/src/babel-plugins/add-tuid-prop.js +++ b/packages/blocks-ui/src/babel-plugins/add-tuid-prop.js @@ -1,7 +1,8 @@ -import { uuid, isBlocksRootElement } from '../util' +import { uuid, isBlocksRootElement, addUuidAttr, getUuidAttr } from '../util' -export default api => { +export default (api, options = {}) => { const { types: t } = api + const { forceUuid } = options return { visitor: { @@ -17,17 +18,17 @@ export default api => { return } - const tuid = path.node.attributes.find( - node => node.name && node.name.name === '___tuid' - ) + const tuid = getUuidAttr(path.node) - if (tuid) { + if (tuid && !forceUuid) { return } - path.node.attributes.push( - t.jSXAttribute(t.jSXIdentifier('___tuid'), t.stringLiteral(uuid())) - ) + if (tuid) { + tuid.value = t.stringLiteral(uuid()) + } else { + addUuidAttr(path.node) + } } } } diff --git a/packages/blocks-ui/src/babel-plugins/add-tuid-prop.test.js b/packages/blocks-ui/src/babel-plugins/add-tuid-prop.test.js new file mode 100644 index 00000000..45a2a9cf --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/add-tuid-prop.test.js @@ -0,0 +1,23 @@ +import { testPlugin } from './test-util' +import plugin from './add-tuid-prop' + +test('adds uuid to JSX elements', () => { + const result = testPlugin(plugin, '

Hello, world!

') + + expect(result).toMatch(/___uuid/) +}) + +test('ignores uuid if it already exists', () => { + const result = testPlugin(plugin, '

Hello, world!

') + + expect(result).toEqual('

Hello, world!

') +}) + +test('changes uuid when forceUuid is set when it already exists', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + forceUuid: true + }) + + expect(result).not.toEqual('

Hello, world!

') + expect(result).toMatch(/___uuid/) +}) diff --git a/packages/blocks-ui/src/babel-plugins/apply-prop.js b/packages/blocks-ui/src/babel-plugins/apply-prop.js index 8af8c161..5c0f23e5 100644 --- a/packages/blocks-ui/src/babel-plugins/apply-prop.js +++ b/packages/blocks-ui/src/babel-plugins/apply-prop.js @@ -1,32 +1,25 @@ -import { toLiteral } from '../util' +import { toJSXAttribute, getUuid } from '../util' -// TODO: Accept the type of value from Controls export default (api, { elementId, key, value } = {}) => { - const { types: t } = api - return { visitor: { JSXOpeningElement(path) { - const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' - ) + const id = getUuid(path.node) - if (!id || id.value.value !== elementId) { + if (!id || id !== elementId) { return } - const attr = path.node.attributes.find( - node => node && node.name && node.name.name === key + const newAttr = toJSXAttribute(api, key, value) + + const existingAttrIndex = path.node.attributes.findIndex( + (node) => node && node.name && node.name.name === key ) - // TODO: Handle the property control/type here. Not - // all props are string literals. - if (attr) { - attr.value = toLiteral(value) + if (existingAttrIndex !== -1) { + path.node.attributes[existingAttrIndex] = newAttr } else { - path.node.attributes.push( - t.JSXAttribute(t.JSXIdentifier(key), toLiteral(value)) - ) + path.node.attributes.push(newAttr) } } } diff --git a/packages/blocks-ui/src/babel-plugins/apply-prop.test.js b/packages/blocks-ui/src/babel-plugins/apply-prop.test.js new file mode 100644 index 00000000..ba2a7609 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/apply-prop.test.js @@ -0,0 +1,32 @@ +import { testPlugin } from './test-util' +import plugin from './apply-prop' + +test('applies a string prop to the element', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + elementId: 'abc', + key: 'foo', + value: 'bar' + }) + + expect(result).toEqual(`

Hello, world!

`) +}) + +test('applies a number prop to the element', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + elementId: 'abc', + key: 'foo', + value: 123 + }) + + expect(result).toEqual(`

Hello, world!

`) +}) + +test('applies a boolean prop to the element', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + elementId: 'abc', + key: 'foo', + value: true + }) + + expect(result).toEqual(`

Hello, world!

`) +}) diff --git a/packages/blocks-ui/src/babel-plugins/apply-sx-prop.js b/packages/blocks-ui/src/babel-plugins/apply-sx-prop.js index 52e645e6..76fb8dd5 100644 --- a/packages/blocks-ui/src/babel-plugins/apply-sx-prop.js +++ b/packages/blocks-ui/src/babel-plugins/apply-sx-prop.js @@ -1,6 +1,5 @@ -import template from '@babel/template' - import { toLiteral } from '../util' +import { uuidName } from '../constants' export default (api, { elementId, sx }) => { const { types: t } = api @@ -9,7 +8,7 @@ export default (api, { elementId, sx }) => { visitor: { JSXOpeningElement(path) { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { @@ -21,13 +20,19 @@ export default (api, { elementId, sx }) => { node => node && node.name && node.name.name === 'sx' ) + if (typeof value === 'undefined') { + return + } + if (!sxProp) { path.node.attributes.push( t.jSXAttribute( t.jSXIdentifier('sx'), - template.ast(`<>{{${key}: '${value}'}}`, { - plugins: ['jsx'] - }).expression.children[0] + t.jsxExpressionContainer( + t.objectExpression([ + t.objectProperty(t.identifier(key), toLiteral(value)) + ]) + ) ) ) } else { diff --git a/packages/blocks-ui/src/babel-plugins/apply-sx-prop.test.js b/packages/blocks-ui/src/babel-plugins/apply-sx-prop.test.js new file mode 100644 index 00000000..55ce7040 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/apply-sx-prop.test.js @@ -0,0 +1,50 @@ +import { testPlugin } from './test-util' +import plugin from './apply-sx-prop' + +test('applies prop to the sx object', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + elementId: 'abc', + sx: { + backgroundColor: 'tomato' + } + }) + + expect(result).toEqual( + `

Hello, world!

` + ) +}) + +test('applies a falsey prop to the sx object', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + elementId: 'abc', + sx: { + fontSize: 0 + } + }) + + expect(result).toEqual( + `

Hello, world!

` + ) +}) + +test('should remove sx value when undefined', () => { + const result = testPlugin(plugin, '

Hello, world!

', { + elementId: 'abc', + sx: { + pt: 3, + pr: undefined, + fontSize: 0 + } + }) + + expect(result).toEqual( + `

Hello, world!

` + ) +}) diff --git a/packages/blocks-ui/src/babel-plugins/clone.js b/packages/blocks-ui/src/babel-plugins/clone.js index 848fd3be..e88f8818 100644 --- a/packages/blocks-ui/src/babel-plugins/clone.js +++ b/packages/blocks-ui/src/babel-plugins/clone.js @@ -1,4 +1,6 @@ -import { uuid } from '../util' +import traverse from '@babel/traverse' + +import { uuid, getUuid, getUuidAttr, addUuidAttr } from '../util' export default (api, { elementId } = {}) => { const { types: t } = api @@ -6,22 +8,40 @@ export default (api, { elementId } = {}) => { return { visitor: { JSXOpeningElement(path) { - const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' - ) + const id = getUuid(path.node) - if (!id || id.value.value !== elementId) { + if (!id || id !== elementId) { return } try { const element = path.parentPath const newElement = t.cloneDeep(element.node) - const tuid = newElement.openingElement.attributes.find( - node => node && node.name && node.name.name === '___tuid' + + // We've found our element and will be cloning, so we can stop all new traversing. + element.stop() + + // Manually run uuid prop adding to the newly created element to ensure that existing + // uuids from cloned children are updated. + traverse( + newElement, + { + JSXOpeningElement(path) { + const id = getUuidAttr(path.node) + + if (id) { + id.value = t.stringLiteral(uuid()) + } else { + addUuidAttr(path.node) + } + } + }, + path.scope, + path.state, + path ) - tuid.value = t.stringLiteral(uuid()) - element.insertBefore(newElement) + + element.insertAfter(newElement) } catch (e) { console.log(e) } diff --git a/packages/blocks-ui/src/babel-plugins/clone.test.js b/packages/blocks-ui/src/babel-plugins/clone.test.js new file mode 100644 index 00000000..81d22d37 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/clone.test.js @@ -0,0 +1,41 @@ +import { testPlugin } from './test-util' +import plugin from './clone' + +test('clones the element', () => { + const result = testPlugin( + plugin, + ` +
+

Hello, world!

+
+ `, + { + elementId: 'abc' + } + ) + + expect(result).toContain('

Hello, world!

Hello, world!

{ + const result = testPlugin( + plugin, + ` +
+

+ Hello, world! +

+
+ `, + { + elementId: 'abc' + } + ) + + // Only match one child element + const substrs = result.split('world!') + expect(substrs.length).toEqual(2) +}) diff --git a/packages/blocks-ui/src/babel-plugins/drag-and-drop.js b/packages/blocks-ui/src/babel-plugins/drag-and-drop.js index c7fec8dc..cdba46ef 100644 --- a/packages/blocks-ui/src/babel-plugins/drag-and-drop.js +++ b/packages/blocks-ui/src/babel-plugins/drag-and-drop.js @@ -1,23 +1,7 @@ import template from '@babel/template' import { isBlocksRootElement } from '../util' - -const navRoot = id => - template.ast( - ` - - {(provided, snapshot) => ( - - {provided.placeholder} - - )} - -`, - { plugins: ['jsx'] } - ) +import { uuidName } from '../constants' const buildDraggable = (id, index) => template.ast( @@ -68,11 +52,11 @@ export default api => { .filter(node => t.isJSXElement(node)) .map((node, i) => { const tuid = node.openingElement.attributes.find( - node => node.name && node.name.name === '___tuid' + node => node.name && node.name.name === uuidName ) if (!tuid) { - return + return null } const id = tuid.value.value diff --git a/packages/blocks-ui/src/babel-plugins/drag-and-drop.test.js b/packages/blocks-ui/src/babel-plugins/drag-and-drop.test.js new file mode 100644 index 00000000..e9d9bb92 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/drag-and-drop.test.js @@ -0,0 +1,21 @@ +import { testPlugin } from './test-util' +import plugin from './drag-and-drop' + +test('add drag and drop wrapper to blocks root component', () => { + const result = testPlugin( + plugin, + `

Hello, world!

` + ) + + expect(result).toEqual( + ` + {(provided, snapshot) =>

Hello, world!

} +
` + ) +}) + +test('should not add drag and drop wrapper to non blocks root component', () => { + const result = testPlugin(plugin, `

Hello, world!

`) + + expect(result).toEqual(`

Hello, world!

`) +}) diff --git a/packages/blocks-ui/src/babel-plugins/get-blocks-usage.test.js b/packages/blocks-ui/src/babel-plugins/get-blocks-usage.test.js new file mode 100644 index 00000000..fb4979e0 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/get-blocks-usage.test.js @@ -0,0 +1,24 @@ +import { testPlugin } from './test-util' +import Plugin from './get-blocks-usage' + +test('should update internal Plugin state with JSX usage', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ usage: null }) + + testPlugin( + plugin.plugin, + `HeaderLogo.usage = \`\` +export default HeaderLogo` + ) + + expect(plugin.state).toEqual({ usage: '' }) +}) + +test('should not update internal Plugin state when no Blocks with usage', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ usage: null }) + + testPlugin(plugin.plugin, `export default HeaderLogo`) + + expect(plugin.state).toEqual({ usage: null }) +}) diff --git a/packages/blocks-ui/src/babel-plugins/get-blocks.js b/packages/blocks-ui/src/babel-plugins/get-blocks.js index 2505d16d..ded5877e 100644 --- a/packages/blocks-ui/src/babel-plugins/get-blocks.js +++ b/packages/blocks-ui/src/babel-plugins/get-blocks.js @@ -1,4 +1,5 @@ import { declare } from '@babel/helper-plugin-utils' + import { getElementName, getUuid } from '../util' class BabelPluginGetBlocks { @@ -27,10 +28,9 @@ class BabelPluginGetBlocks { const children = path.node.children.filter(c => !t.isJSXText(c)) - children.map(child => { + children.forEach(child => { const id = getUuid(child.openingElement) const name = getElementName(child.openingElement) - this.state.blocks.push({ id, name }) }) } diff --git a/packages/blocks-ui/src/babel-plugins/get-blocks.test.js b/packages/blocks-ui/src/babel-plugins/get-blocks.test.js new file mode 100644 index 00000000..7628878b --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/get-blocks.test.js @@ -0,0 +1,26 @@ +import { testPlugin } from './test-util' +import Plugin from './get-blocks' + +test('should update internal Plugin state with root block children name and id', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ blocks: [] }) + + testPlugin( + plugin.plugin, + `Hello, world!` + ) + + expect(plugin.state).toEqual({ blocks: [{ id: 'abc', name: 'HeaderBasic' }] }) +}) + +test('should not update internal Plugin state when no Blocks Root element preset', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ blocks: [] }) + + testPlugin( + plugin.plugin, + `Hello, world!` + ) + + expect(plugin.state).toEqual({ blocks: [] }) +}) diff --git a/packages/blocks-ui/src/babel-plugins/get-current-element.js b/packages/blocks-ui/src/babel-plugins/get-current-element.js index a7d5ead8..98d52618 100644 --- a/packages/blocks-ui/src/babel-plugins/get-current-element.js +++ b/packages/blocks-ui/src/babel-plugins/get-current-element.js @@ -2,6 +2,7 @@ import { declare } from '@babel/helper-plugin-utils' import * as t from '@babel/types' import { textTrim, getElementName, getUuid } from '../util' +import { uuidName } from '../constants' const getElementProps = (attributes = {}) => { const props = attributes.reduce((acc, curr) => { @@ -35,7 +36,7 @@ const getParentId = node => { } const id = openingElement.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) return id && id.value && id.value.value @@ -53,7 +54,7 @@ class BabelPluginGetCurrentElement { visitor: { JSXOpeningElement: path => { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { @@ -74,9 +75,8 @@ class BabelPluginGetCurrentElement { element.children = children .map(c => { if (t.isJSXText(c)) { - return + return false } - return { id: getUuid(c.openingElement), name: getElementName(c.openingElement) diff --git a/packages/blocks-ui/src/babel-plugins/get-current-element.test.js b/packages/blocks-ui/src/babel-plugins/get-current-element.test.js new file mode 100644 index 00000000..28e7fb34 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/get-current-element.test.js @@ -0,0 +1,85 @@ +import { testPlugin } from './test-util' +import Plugin from './get-current-element' + +test('should update internal Plugin state with current selected element', () => { + const plugin = new Plugin() + const expected = { + id: 'abc', + parentId: undefined, + children: [ + { + id: 'def', + name: 'HeaderBasic.Nav' + } + ], + name: 'HeaderBasic', + props: { + ___uuid: 'abc', + sx: {} + } + } + expect(plugin.state).toEqual({ element: null }) + + testPlugin( + plugin.plugin, + ` + + + Hello + + + `, + { elementId: 'abc' } + ) + + expect(plugin.state).toEqual({ element: expected }) +}) + +test('should update Plugin state with current element and track parent id', () => { + const plugin = new Plugin() + const expected = { + id: 'ghi', + name: 'HeaderBasic.Logo', + parentId: 'def', + props: { + ___uuid: 'ghi', + sx: {}, + to: '/' + }, + text: 'Hello' + } + expect(plugin.state).toEqual({ element: null }) + + testPlugin( + plugin.plugin, + ` + + + Hello + + + `, + { elementId: 'ghi' } + ) + + expect(plugin.state).toEqual({ element: expected }) +}) + +test('should not update Plugin state with current element when id does not match any id', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ element: null }) + + testPlugin( + plugin.plugin, + ` + + + Hello + + + `, + { elementId: 'jkl' } + ) + + expect(plugin.state).toEqual({ element: null }) +}) diff --git a/packages/blocks-ui/src/babel-plugins/get-element-tree.js b/packages/blocks-ui/src/babel-plugins/get-element-tree.js new file mode 100644 index 00000000..b6fb615c --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/get-element-tree.js @@ -0,0 +1,92 @@ +import { declare } from '@babel/helper-plugin-utils' + +import { getElementName, getUuid } from '../util' + +// This plugin generates a tree of JSX elements from an AST. +// The generated `Tree` is an object with the following structure: +// { id: string, name: string, children: [Tree] } + +class BabelPluginGetTree { + constructor() { + this.state = { tree: null } + + // When we enter a JSX element, we create a tree node and push it onto the + // stack. When Babel finishes traversing the element's children, we pop + // the node from the stack and add it to the `children` array of the node + // that is now on the top of the stack. + const stack = [] + + this.plugin = declare(api => { + api.assertVersion(7) + + return { + visitor: { + Program: { + // We use `Program` as an arbitrary entrypoint to create the + // root of our tree because: + // - Babel enters `Program` before visiting any JSX elements. + // - Babel exits `Program` after visiting all JSX elements. + // + // One problem with using `Program` as an entrypoint is that our + // tree will include all JSX elements in the code, not just JSX + // elements in the default export. + // + // For example, if the code looked like this: + // const App = () => + // const OtherComponent = () => + // export default App + // + // ...the generated tree will look like this: + // root + // β”œβ”€β”€ Foo + // └── Bar + // + // We probably want the tree to look like this instead: + // root + // └── Foo + enter: path => { + stack.push({ id: 'root', name: 'root', children: [] }) + }, + exit: path => { + this.state.tree = stack.pop() + } + }, + JSXElement: { + enter: path => { + const id = getUuid(path.node.openingElement) + const name = getElementName(path.node.openingElement) + + // If the element doesn't have a uuid attribute, we don't include + // it in the tree. This allows to exclude blocks elements from the + // tree because they don't have uuids. + if (!id) { + return + } + + stack.push({ id, name, children: [] }) + }, + exit: path => { + const id = getUuid(path.node.openingElement) + + // Since we don't push elements that don't have uuids on the stack + // when we enter, we need to make sure that we don't pop from the + // stack when we exit. Otherwise, we'll end up popping more times + // than we push. + if (!id) { + return + } + + const element = stack.pop() + if (stack.length > 0) { + const parent = stack[stack.length - 1] + parent.children.push(element) + } + } + } + } + } + }) + } +} + +export default BabelPluginGetTree diff --git a/packages/blocks-ui/src/babel-plugins/get-element-tree.test.js b/packages/blocks-ui/src/babel-plugins/get-element-tree.test.js new file mode 100644 index 00000000..3f35930e --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/get-element-tree.test.js @@ -0,0 +1,68 @@ +import { testPlugin } from './test-util' +import Plugin from './get-element-tree' + +test('should generate a tree of JSX elements from an AST', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ tree: null }) + + const expected = { + id: 'root', + name: 'root', + children: [ + { + id: 'abc', + name: 'HeaderBasic', + children: [ + { + id: 'def', + name: 'HeaderBasic.Nav', + children: [ + { + id: 'ghi', + name: 'HeaderBasic.Logo', + children: [] + } + ] + } + ] + } + ] + } + + testPlugin( + plugin.plugin, + ` + + + Hello + + + ` + ) + + expect(plugin.state).toEqual({ tree: expected }) +}) + +test('should not generate a tree of JSX elements from an AST without ___uuid props', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ tree: null }) + + const expected = { + id: 'root', + name: 'root', + children: [] + } + + testPlugin( + plugin.plugin, + ` +
+
+ Hello +
+
+
` + ) + + expect(plugin.state).toEqual({ tree: expected }) +}) diff --git a/packages/blocks-ui/src/babel-plugins/get-exported-elements.test.js b/packages/blocks-ui/src/babel-plugins/get-exported-elements.test.js new file mode 100644 index 00000000..637eca0f --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/get-exported-elements.test.js @@ -0,0 +1,25 @@ +import { testPlugin } from './test-util' +import Plugin from './get-exported-elements' + +test('should update internal Plugin state with ExportNamedDeclaration definitions', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ elements: {} }) + + testPlugin(plugin.plugin, `export const HeaderBasic = () =>
`) + + expect(plugin.state.elements).toMatchSnapshot() +}) + +test('should not updated internal Plugin state when no named exports in tree', () => { + const plugin = new Plugin() + expect(plugin.state).toEqual({ elements: {} }) + + testPlugin( + plugin.plugin, + `const HeaderBasic = () =>
+export default HeaderBasic + ` + ) + + expect(plugin.state).toEqual({ elements: {} }) +}) diff --git a/packages/blocks-ui/src/babel-plugins/inject-blocks-root.js b/packages/blocks-ui/src/babel-plugins/inject-blocks-root.js index efce4475..c447d148 100644 --- a/packages/blocks-ui/src/babel-plugins/inject-blocks-root.js +++ b/packages/blocks-ui/src/babel-plugins/inject-blocks-root.js @@ -4,18 +4,28 @@ const root = template.ast( ` const BLOCKS_Root = ({ children }) => { return ( - - {(provided, snapshot) => ( -
- {children} - {provided.placeholder} -
- )} -
+ + + {(provided, snapshot) => { + const allProps = Object.assign( + provided.droppableProps, + { + ref: provided.innerRef, + style: { + minHeight: '100%' + }, + } + ) + + return React.createElement( + 'div', + allProps, + children, + provided.placeholder + ) + }} + + ) } `, diff --git a/packages/blocks-ui/src/babel-plugins/inject-blocks-root.test.js b/packages/blocks-ui/src/babel-plugins/inject-blocks-root.test.js new file mode 100644 index 00000000..c68dd778 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/inject-blocks-root.test.js @@ -0,0 +1,32 @@ +import { testPlugin } from './test-util' +import plugin from './inject-blocks-root' + +test('should inject root block element', () => { + const result = testPlugin(plugin, `
Hello, world!
`) + + expect(result).toEqual(`
Hello, world!
; + +const BLOCKS_Root = ({ + children +}) => { + return + + {(provided, snapshot) => { + const allProps = Object.assign(provided.droppableProps, { + ref: provided.innerRef, + style: { + minHeight: '100%' + } + }); + return React.createElement('div', allProps, children, provided.placeholder); + }} + + ; +}`) +}) + +test('should not inject root block element when program empty', () => { + const result = testPlugin(plugin, ``) + + expect(result).toBe('') +}) diff --git a/packages/blocks-ui/src/babel-plugins/insert-after.js b/packages/blocks-ui/src/babel-plugins/insert-after.js index 16f7bd52..c3c1033f 100644 --- a/packages/blocks-ui/src/babel-plugins/insert-after.js +++ b/packages/blocks-ui/src/babel-plugins/insert-after.js @@ -1,5 +1,7 @@ import template from '@babel/template' +import { uuidName } from '../constants' + export default (_, { elementId } = {}) => { const ast = template.ast(`

hello!

`, { plugins: ['jsx'] @@ -9,7 +11,7 @@ export default (_, { elementId } = {}) => { visitor: { JSXOpeningElement(path) { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { diff --git a/packages/blocks-ui/src/babel-plugins/insert-after.test.js b/packages/blocks-ui/src/babel-plugins/insert-after.test.js new file mode 100644 index 00000000..931c22d7 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/insert-after.test.js @@ -0,0 +1,18 @@ +import { testPlugin } from './test-util' +import plugin from './insert-after' + +test('should insert template ast after node', () => { + const result = testPlugin(plugin, `
Hello, world!
`, { + elementId: 'abc' + }) + expect(result).toContain( + '
Hello, world!
;\n

hello!

' + ) +}) + +test('should not insert template ast after node when id does not exist', () => { + const result = testPlugin(plugin, `
Hello, world!
`, { + elementId: 'efg' + }) + expect(result).toContain('
Hello, world!
') +}) diff --git a/packages/blocks-ui/src/babel-plugins/insert-before.js b/packages/blocks-ui/src/babel-plugins/insert-before.js index e03394da..dc604fa8 100644 --- a/packages/blocks-ui/src/babel-plugins/insert-before.js +++ b/packages/blocks-ui/src/babel-plugins/insert-before.js @@ -1,5 +1,7 @@ import template from '@babel/template' +import { uuidName } from '../constants' + export default (_, { elementId } = {}) => { const ast = template.ast(`

hello!

`, { plugins: ['jsx'] @@ -9,7 +11,7 @@ export default (_, { elementId } = {}) => { visitor: { JSXOpeningElement(path) { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { diff --git a/packages/blocks-ui/src/babel-plugins/insert-before.test.js b/packages/blocks-ui/src/babel-plugins/insert-before.test.js new file mode 100644 index 00000000..b4164b52 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/insert-before.test.js @@ -0,0 +1,18 @@ +import { testPlugin } from './test-util' +import plugin from './insert-before' + +test('should insert template ast before node', () => { + const result = testPlugin(plugin, `
Hello, world!
`, { + elementId: 'abc' + }) + expect(result).toContain( + '

hello!

\n
Hello, world!
' + ) +}) + +test('should not insert template ast before node when id is not in scope', () => { + const result = testPlugin(plugin, `
Hello, world!
`, { + elementId: 'efg' + }) + expect(result).toContain('
Hello, world!
') +}) diff --git a/packages/blocks-ui/src/babel-plugins/insert-block.test.js b/packages/blocks-ui/src/babel-plugins/insert-block.test.js new file mode 100644 index 00000000..fddd485a --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/insert-block.test.js @@ -0,0 +1,59 @@ +import { testPlugin } from './test-util' +import plugin from './insert-block' + +test('should insert template ast at destination.index', () => { + const result = testPlugin(plugin, ``, { + destination: { + droppableId: 'root', + index: 0 + }, + block: { + name: 'HeaderBasic', + usage: `Hello, world!` + } + }) + + expect(result).toContain( + 'Hello, world!' + ) +}) + +test('should throw when block usage incorrect', () => { + const result = testPlugin(plugin, ``, { + destination: { + droppableId: 'root', + index: 0 + }, + block: { name: undefined } + }) + + expect(result).toBe(`
+ + Failed to compile undefined + + +
+      {'"Unexpected template param undefined"'}
+    
+
`) +}) + +test('should not insert block when no destination defined', () => { + const result = testPlugin(plugin, ``, { + destination: null, + block: { + name: 'HeaderBasic', + usage: `Hello, world!` + } + }) + + expect(result).toContain('') +}) diff --git a/packages/blocks-ui/src/babel-plugins/remove-imports.test.js b/packages/blocks-ui/src/babel-plugins/remove-imports.test.js new file mode 100644 index 00000000..2c341834 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/remove-imports.test.js @@ -0,0 +1,21 @@ +import { testPlugin } from './test-util' +import plugin from './remove-imports' + +test('should remove import decelerations', () => { + const result = testPlugin( + plugin, + `import React from 'react' +export default () => ()` + ) + + expect(result).toBe('export default (() => )') +}) + +test('should not update source when no import declaration', () => { + const result = testPlugin( + plugin, + `export default () => ()` + ) + + expect(result).toBe('export default (() => )') +}) diff --git a/packages/blocks-ui/src/babel-plugins/remove-named-exports.test.js b/packages/blocks-ui/src/babel-plugins/remove-named-exports.test.js new file mode 100644 index 00000000..ad3c6da5 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/remove-named-exports.test.js @@ -0,0 +1,14 @@ +import { testPlugin } from './test-util' +import plugin from './remove-named-exports' + +test('should remove named export decelerations', () => { + const result = testPlugin(plugin, `export const Block = 'abc'`) + + expect(result).toBe(`const Block = 'abc'`) +}) + +test('should export as default', () => { + const result = testPlugin(plugin, `export { Block as default } from '.'`) + + expect(result).toBe("export { Block as default } from '.'") +}) diff --git a/packages/blocks-ui/src/babel-plugins/remove-sx-prop.js b/packages/blocks-ui/src/babel-plugins/remove-sx-prop.js index c024770f..78a722ae 100644 --- a/packages/blocks-ui/src/babel-plugins/remove-sx-prop.js +++ b/packages/blocks-ui/src/babel-plugins/remove-sx-prop.js @@ -1,9 +1,11 @@ +import { uuidName } from '../constants' + export default (api, { elementId, key }) => { return { visitor: { JSXOpeningElement(path) { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { @@ -18,7 +20,9 @@ export default (api, { elementId, key }) => { return } - sxProp.value.expression.properties.filter(n => n.key.name !== key) + sxProp.value.expression.properties = sxProp.value.expression.properties.filter( + n => n.key.name !== key + ) } } } diff --git a/packages/blocks-ui/src/babel-plugins/remove-sx-prop.test.js b/packages/blocks-ui/src/babel-plugins/remove-sx-prop.test.js new file mode 100644 index 00000000..55c7a42a --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/remove-sx-prop.test.js @@ -0,0 +1,49 @@ +import { testPlugin } from './test-util' +import plugin from './remove-sx-prop' + +test('should remove sx property', () => { + const result = testPlugin( + plugin, + `
Hello, world!
`, + { + elementId: 'abc', + key: 'pt' + } + ) + + expect(result).toBe(`
Hello, world!
`) +}) + +test('should not remove sx property when elementId does not match', () => { + const result = testPlugin( + plugin, + `
Hello, world!
`, + { + elementId: 'efg', + key: 'pt' + } + ) + + expect(result).toBe(`
Hello, world!
`) +}) + +test('should not remove sx property sx property does not exist', () => { + const result = testPlugin( + plugin, + `
Hello, world!
`, + { + elementId: 'abc', + key: 'mt' + } + ) + + expect(result).toBe(`
Hello, world!
`) +}) diff --git a/packages/blocks-ui/src/babel-plugins/remove-tuid.js b/packages/blocks-ui/src/babel-plugins/remove-tuid.js index 6712deaa..2f60f878 100644 --- a/packages/blocks-ui/src/babel-plugins/remove-tuid.js +++ b/packages/blocks-ui/src/babel-plugins/remove-tuid.js @@ -1,3 +1,5 @@ +import { uuidName } from '../constants' + export default () => { return { visitor: { @@ -10,7 +12,7 @@ export default () => { } const attributesWithoutUuid = path.node.attributes.filter( - node => node.name && node.name.name !== '___tuid' + node => node.name && node.name.name !== uuidName ) path.node.attributes = attributesWithoutUuid } diff --git a/packages/blocks-ui/src/babel-plugins/remove-tuid.test.js b/packages/blocks-ui/src/babel-plugins/remove-tuid.test.js new file mode 100644 index 00000000..c9d38d80 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/remove-tuid.test.js @@ -0,0 +1,7 @@ +import { testPlugin } from './test-util' +import plugin from './remove-tuid' + +test('removes uuid for div', () => { + const result = testPlugin(plugin, `
Hello, world!
`) + expect(result).toContain('
Hello, world!
') +}) diff --git a/packages/blocks-ui/src/babel-plugins/remove.js b/packages/blocks-ui/src/babel-plugins/remove.js index e0ec35af..c77ac49c 100644 --- a/packages/blocks-ui/src/babel-plugins/remove.js +++ b/packages/blocks-ui/src/babel-plugins/remove.js @@ -1,9 +1,11 @@ +import { uuidName } from '../constants' + export default (api, { elementId } = {}) => { return { visitor: { JSXOpeningElement(path) { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { diff --git a/packages/blocks-ui/src/babel-plugins/remove.test.js b/packages/blocks-ui/src/babel-plugins/remove.test.js new file mode 100644 index 00000000..d61144d3 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/remove.test.js @@ -0,0 +1,22 @@ +import { testPlugin } from './test-util' +import plugin from './remove' + +test('should remove node when id matches', () => { + const result = testPlugin( + plugin, + `

Hello, world!

`, + { + elementId: 'abc' + } + ) + + expect(result).toBe('') +}) + +test('should not remove node when id does not match', () => { + const result = testPlugin(plugin, `

Hello, world!

`, { + elementId: 'def' + }) + + expect(result).toBe('

Hello, world!

') +}) diff --git a/packages/blocks-ui/src/babel-plugins/reorder-blocks.test.js b/packages/blocks-ui/src/babel-plugins/reorder-blocks.test.js new file mode 100644 index 00000000..027e9ad5 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/reorder-blocks.test.js @@ -0,0 +1,58 @@ +import { testPlugin } from './test-util' +import plugin from './reorder-blocks' + +test('should reorder blocks when source and destination index', () => { + const result = testPlugin( + plugin, + ` +
Hello, world!
+
Hello, world!
+
`, + { + destination: { index: 1 }, + source: { index: 0 } + } + ) + + expect(result).toBe( + '
Hello, world!
Hello, world!
' + ) +}) + +test('should not reorder blocks when no source or destination index', () => { + const result = testPlugin( + plugin, + ` +
Hello, world!
+
Hello, world!
+
`, + { + destination: null, + source: null + } + ) + + expect(result).toEqual(` +
Hello, world!
+
Hello, world!
+
`) +}) + +test('should not reorder blocks when no root Block', () => { + const result = testPlugin( + plugin, + `<> +
Hello, world!
+
Hello, world!
+`, + { + destination: { index: 1 }, + source: { index: 0 } + } + ) + + expect(result).toEqual(`<> +
Hello, world!
+
Hello, world!
+`) +}) diff --git a/packages/blocks-ui/src/babel-plugins/replace-text.js b/packages/blocks-ui/src/babel-plugins/replace-text.js index 1b71ee0d..a8f7f1e8 100644 --- a/packages/blocks-ui/src/babel-plugins/replace-text.js +++ b/packages/blocks-ui/src/babel-plugins/replace-text.js @@ -1,3 +1,5 @@ +import { uuidName } from '../constants' + export default (api, { elementId, text } = {}) => { const { types: t } = api @@ -5,7 +7,7 @@ export default (api, { elementId, text } = {}) => { visitor: { JSXOpeningElement(path) { const id = path.node.attributes.find( - node => node && node.name && node.name.name === '___tuid' + node => node && node.name && node.name.name === uuidName ) if (!id || id.value.value !== elementId) { diff --git a/packages/blocks-ui/src/babel-plugins/replace-text.test.js b/packages/blocks-ui/src/babel-plugins/replace-text.test.js new file mode 100644 index 00000000..847acf71 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/replace-text.test.js @@ -0,0 +1,20 @@ +import { testPlugin } from './test-util' +import plugin from './replace-text' + +test('should replace text on node', () => { + const result = testPlugin(plugin, `
hello world
`, { + elementId: 'abc', + text: 'Hello, world!' + }) + + expect(result).toBe('
Hello, world!
') +}) + +test('should not replace text when id does not match nodes id', () => { + const result = testPlugin(plugin, `
hello world
`, { + elementId: 'efg', + text: 'Hello, world!' + }) + + expect(result).toBe('
hello world
') +}) diff --git a/packages/blocks-ui/src/babel-plugins/set-default-export-to-container.test.js b/packages/blocks-ui/src/babel-plugins/set-default-export-to-container.test.js new file mode 100644 index 00000000..e3d4edd7 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/set-default-export-to-container.test.js @@ -0,0 +1,20 @@ +import { testPlugin } from './test-util' +import plugin from './set-default-export-to-container' + +test('should convert default export declaration to BLOCKS_Container', () => { + const result = testPlugin( + plugin, + `export default () =>
Hello, world!
` + ) + + expect(result).toBe('const BLOCKS_Container = () =>
Hello, world!
') +}) + +test('should ignore non default export declarations', () => { + const result = testPlugin( + plugin, + `export const Test = () =>
Hello, world!
` + ) + + expect(result).toBe('export const Test = () =>
Hello, world!
') +}) diff --git a/packages/blocks-ui/src/babel-plugins/test-util.js b/packages/blocks-ui/src/babel-plugins/test-util.js new file mode 100644 index 00000000..779e3641 --- /dev/null +++ b/packages/blocks-ui/src/babel-plugins/test-util.js @@ -0,0 +1,8 @@ +import { transform } from '@babel/standalone' +import babelPluginSyntaxJsx from '@babel/plugin-syntax-jsx' + +export const testPlugin = (plugin, src, opts = {}) => { + return transform(src, { + plugins: [babelPluginSyntaxJsx, [plugin, opts]] + }).code.replace(/;$/, '') +} diff --git a/packages/blocks-ui/src/block-error.js b/packages/blocks-ui/src/block-error.js index ae13f3c0..3208c93e 100644 --- a/packages/blocks-ui/src/block-error.js +++ b/packages/blocks-ui/src/block-error.js @@ -1,5 +1,4 @@ /** @jsx jsx */ -import React from 'react' import { jsx, Styled } from 'theme-ui' export default ({ name, message }) => ( diff --git a/packages/blocks-ui/src/blocks-listing.js b/packages/blocks-ui/src/blocks-listing.js deleted file mode 100644 index 3f820996..00000000 --- a/packages/blocks-ui/src/blocks-listing.js +++ /dev/null @@ -1,67 +0,0 @@ -/** @jsx jsx */ -import React, { useMemo } from 'react' -import { Droppable, Draggable } from 'react-beautiful-dnd' -import { jsx, ThemeProvider } from 'theme-ui' - -import InlineBlockRender from './inline-block-render' - -const isBlocksRoot = component => - component.Root && Object.keys(component).length === 1 - -export default ({ components, theme }) => { - const list = useMemo(() => { - return Object.keys(components).map((key, i) => { - const Component = components[key] - - // Ignore Blocks.Root since it's a special component - if (isBlocksRoot(Component)) { - return null - } - - return ( - - {(provided, snapshot) => { - const Component = components[key] - - return ( -
-
- -
-
- ) - }} -
- ) - }) - }, [components]) - - return ( - - - {(provided, snapshot) => ( -
- {list} - {provided.placeholder} -
- )} -
-
- ) -} diff --git a/packages/blocks-ui/src/canvas.js b/packages/blocks-ui/src/canvas.js index 70db58b4..dc34e967 100644 --- a/packages/blocks-ui/src/canvas.js +++ b/packages/blocks-ui/src/canvas.js @@ -1,96 +1,61 @@ /** @jsx jsx */ -import React, { useState } from 'react' -import { jsx, Styled } from 'theme-ui' -import prettier from 'prettier/standalone' -import parserJS from 'prettier/parser-babylon' +import { useRef, useEffect } from 'react' +import { jsx } from 'theme-ui' +import Loadable from 'react-loadable' -import { useEditor } from './editor-context' -import InlineRender from './inline-render' +import { Loader } from './loader' -import { Clipboard, Check } from 'react-feather' -import { IconButton } from './ui' +import { useEditor } from './providers/editor' +import { useCanvas } from './providers/canvas' +import { useElementSize } from './use-element-size' -const Wrap = props => ( -
-) +const CodeMode = Loadable({ + loader: () => import('./modes/code'), + loading: Loader +}) -function copyToClipboard(toCopy) { - const el = document.createElement(`textarea`) - el.value = toCopy - el.setAttribute(`readonly`, ``) - el.style.position = `absolute` - el.style.left = `-9999px` - document.body.appendChild(el) - el.select() - document.execCommand(`copy`) - document.body.removeChild(el) -} - -const Copy = ({ toCopy }) => { - const [hasCopied, setHasCopied] = useState(false) +const ViewportsMode = Loadable({ + loader: () => import('./modes/viewports'), + loading: Loader +}) - function copyToClipboardOnClick() { - if (hasCopied) return +const CanvasMode = Loadable({ + loader: () => import('./modes/canvas'), + loading: Loader +}) - copyToClipboard(toCopy) - setHasCopied(true) +export const CanvasWrap = props => { + const canvasRef = useRef() + const elementSize = useElementSize(canvasRef) + const { setCanvasSize } = useCanvas() - setTimeout(() => { - setHasCopied(false) - }, 2000) - } + useEffect(() => { + setCanvasSize(elementSize) + }, [elementSize]) return ( - - {hasCopied ? ( - - ) : ( - - )} - +
) } -export default ({ code, transformedCode, scope, theme }) => { +export const Canvas = () => { const { mode } = useEditor() - const formattedCode = prettier.format(code, { - parser: 'babel', - plugins: [parserJS] - }) - - if (mode === 'code') { - return ( - - - - {formattedCode} - - - ) + switch (mode) { + case 'code': + return + case 'viewports': + return + case 'canvas': + return + default: + return null } - - return ( - - - - ) } diff --git a/packages/blocks-ui/src/code-editor.js b/packages/blocks-ui/src/code-editor.js new file mode 100644 index 00000000..d2bb78c5 --- /dev/null +++ b/packages/blocks-ui/src/code-editor.js @@ -0,0 +1,64 @@ +/** @jsx jsx */ +import { useRef } from 'react' +import { jsx } from 'theme-ui' +import Monaco from '@monaco-editor/react' +import { Clipboard, Check } from 'react-feather' + +import useCopyToClipboard from './use-copy-to-clipboard' +import { Loader } from './loader' +import { IconButton } from './ui' + +const Copy = ({ toCopy }) => { + const { hasCopied, copyToClipboard } = useCopyToClipboard() + + return ( + copyToClipboard(toCopy)} + sx={{ + position: 'absolute', + top: 0, + right: 0, + zIndex: 1, + svg: { + fill: hasCopied ? 'green' : null + } + }} + icon={hasCopied ? Check : Clipboard} + /> + ) +} + +export const CodeEditor = ({ code, onChange }) => { + const editorRef = useRef(null) + + const handleEditorDidMount = (_, editor) => { + editorRef.current = editor + + editorRef.current.onDidChangeModelContent(ev => { + onChange(editorRef.current.getValue()) + }) + } + + return ( +
+ + } + options={{ + minimap: { + enabled: false + }, + scrollbar: { + vertical: 'hidden' + } + }} + /> +
+ ) +} diff --git a/packages/blocks-ui/src/constants.js b/packages/blocks-ui/src/constants.js new file mode 100644 index 00000000..3f6877de --- /dev/null +++ b/packages/blocks-ui/src/constants.js @@ -0,0 +1 @@ +export const uuidName = '___uuid' diff --git a/packages/blocks-ui/src/device-preview.js b/packages/blocks-ui/src/device-preview.js new file mode 100644 index 00000000..65a4156c --- /dev/null +++ b/packages/blocks-ui/src/device-preview.js @@ -0,0 +1,213 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { Label, Checkbox, Slider } from '@theme-ui/components' +import { + Children, + cloneElement, + useLayoutEffect, + useRef, + useState, + useMemo +} from 'react' +import { createPortal } from 'react-dom' +import { ZoomIn, ZoomOut } from 'react-feather' +import { CacheProvider, Global } from '@emotion/core' +import createCache from '@emotion/cache' + +import { IconButton } from './ui' +import { useScrollSync } from './hooks' + +const MIN_ZOOM_LEVEL = 25 + +const Frame = ({ children, setFrame, ...restProps }) => { + const frameRef = useRef() + const [{ head, body }, setNodes] = useState({ head: null, body: null }) + + useLayoutEffect(() => { + const { + contentDocument: { head, body } + } = frameRef.current + setFrame(frameRef.current) + setNodes({ head, body }) + }, []) + + const emotionCache = useMemo(() => { + if (!head || !body) return null + return createCache({ + key: 'device-container', + container: head + }) + }, [head]) + + return ( + + ) +} + +export const Device = ({ children, width, height, setFrame, zoomLevel }) => { + width = parseFloat(width) + height = parseFloat(height) + return ( +
+
+ {width}px +
+
+ + {children} + +
+
+ ) +} + +export const PreviewArea = ({ children }) => { + const [wrap, setWrap] = useState(false) + const [zoomLevel, setZoomLevel] = useState(1) + const shiftKeyDown = useRef() + const frames = useRef([]) + const setFrame = index => ref => (frames.current[index] = ref) + + useScrollSync(frames) + + return ( +
+
+ (shiftKeyDown.current = event.shiftKey)} + onChange={event => { + const nextZoomLevel = parseFloat(event.target.value) / 100 + const shiftAmount = nextZoomLevel < zoomLevel ? -0.1 : 0.1 + setZoomLevel( + nextZoomLevel + (shiftKeyDown.current ? shiftAmount : 0) + ) + }} + css={{ + appearance: 'none', + WebkitAppearance: 'none', + width: 30, + fontSize: 16, + border: 'none', + backgroundColor: 'transparent', + '&::-webkit-inner-spin-button, &::-webkit-outer-spin-button': { + WebkitAppearance: 'none', + margin: 0 + } + }} + /> + % + { + const nextZoomLevel = zoomLevel - 0.1 + if (nextZoomLevel > 0.25) { + setZoomLevel(nextZoomLevel) + } + }} + /> + setZoomLevel(parseFloat(event.target.value) / 100)} + style={{ width: 80 }} + /> + setZoomLevel(zoomLevel + 0.1)} + icon={ZoomIn} + /> + +
+
+
+ {Children.map(children, (child, index) => + cloneElement(child, { + setFrame: setFrame(index), + zoomLevel + }) + )} +
+
+
+ ) +} diff --git a/packages/blocks-ui/src/editor-context.js b/packages/blocks-ui/src/editor-context.js deleted file mode 100644 index 2f123218..00000000 --- a/packages/blocks-ui/src/editor-context.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useState, useContext } from 'react' - -const EDITOR_TAB_INDEX = 0 -const COMPONENTS_TAB_INDEX = 1 -const THEME_TAB_INDEX = 2 - -const CANVAS_MODE = 'canvas' - -const TAB_MAP = { - [EDITOR_TAB_INDEX]: 'editor', - [COMPONENTS_TAB_INDEX]: 'components', - [THEME_TAB_INDEX]: 'theme' -} - -const getActiveTabName = index => TAB_MAP[index] - -const EditorContext = React.createContext(null) - -export const useEditor = () => { - const value = useContext(EditorContext) - - return value -} - -export const EditorProvider = ({ children }) => { - const [value, update] = useState({ - //activeTab: EDITOR_TAB_INDEX, - mode: 'canvas' - }) - - // TODO: Make tabs use context - //const activeTabName = getActiveTabName(value.activeTab) - - return ( - - {children} - - ) -} diff --git a/packages/blocks-ui/src/editor-panel.js b/packages/blocks-ui/src/editor-panel.js deleted file mode 100644 index c12e48ec..00000000 --- a/packages/blocks-ui/src/editor-panel.js +++ /dev/null @@ -1,99 +0,0 @@ -/** @jsx jsx */ -import React from 'react' -import { jsx } from 'theme-ui' -import { Label, Input, Flex } from '@theme-ui/components' -import { Trash, CornerRightUp, Copy, List } from 'react-feather' - -import { IconButton } from './ui' - -import TreeView from './tree-view' -import PropertyControlsPanel from './property-controls-panel' - -// TODO: Fix this prop drilling dance for common editor -// interactions. -export default ({ - elementData, - handleChange, - handlePropChange, - handleRemoveElement, - handleParentSelect, - handleClone, - handleTextUpdate, - setElementId, - blocks -}) => { - const keys = elementData.name.split('.') - - let block = blocks - keys.forEach(key => { - if (!block) { - return {} - } - - block = block[key] - }) - - const { propertyControls = {} } = block || {} - - return ( -
- -

- {elementData.name} -

- -
- {block && ( - - )} - -
- ) -} diff --git a/packages/blocks-ui/src/editor.js b/packages/blocks-ui/src/editor.js new file mode 100644 index 00000000..bd29ccf6 --- /dev/null +++ b/packages/blocks-ui/src/editor.js @@ -0,0 +1,70 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { DragDropContext } from '@blocks/react-beautiful-dnd' + +import appTheme from './theme' +import { Canvas } from './canvas' +import Layout from './layout' +import { headerHeight } from './header' +import SidePanel from './panels/side' +import TreePanel from './panels/tree' +import Providers from './providers' +import { useCode } from './providers/code' + +const EditorGrid = props => ( +
+) + +const Editor = () => { + const { onDragEnd, onBeforeDragStart } = useCode() + + return ( + + + + + + + + + + ) +} + +const EditorWithProviders = ({ + src, + blocks, + theme, + props = {}, + scope = {}, + layout = 'div', + onChange = () => {} +}) => ( + + + +) + +export default EditorWithProviders diff --git a/packages/blocks-ui/src/element-context.js b/packages/blocks-ui/src/element-context.js deleted file mode 100644 index b817e8fb..00000000 --- a/packages/blocks-ui/src/element-context.js +++ /dev/null @@ -1,12 +0,0 @@ -import React, { useContext } from 'react' - -const ElementContext = React.createContext({}) - -export const useElement = () => { - const element = useContext(ElementContext) - return element || {} -} - -export const ElementProvider = ElementContext.Provider - -export default ElementContext diff --git a/packages/blocks-ui/src/field-group.js b/packages/blocks-ui/src/field-group.js new file mode 100644 index 00000000..2adccb43 --- /dev/null +++ b/packages/blocks-ui/src/field-group.js @@ -0,0 +1,35 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { Grid } from '@theme-ui/components' + +import appTheme from './theme' + +export const FieldGroup = ({ title, children, ...props }) => ( +
+ {title && ( +

+ {title} +

+ )} + {children} +
+) diff --git a/packages/blocks-ui/src/header.js b/packages/blocks-ui/src/header.js index b2063133..b9ebb9a9 100644 --- a/packages/blocks-ui/src/header.js +++ b/packages/blocks-ui/src/header.js @@ -1,53 +1,130 @@ /** @jsx jsx */ import { jsx } from 'theme-ui' -import { Code, Layers } from 'react-feather' +import { useState } from 'react' +import { Code, Layers, Monitor, Grid } from 'react-feather' -import { useEditor } from './editor-context' +import pkg from '../package.json' + +import { useEditor } from './providers/editor' +import { SegmentedControl } from './segmented-control' import { IconButton } from './ui' -export default () => { +const { version } = pkg + +export const headerHeight = 60 + +const MODES = [ + { + key: 'canvas', + label: 'Canvas Mode', + icon: Layers + }, + { + key: 'viewports', + label: 'Viewports Mode', + icon: Monitor + }, + { + key: 'code', + label: 'Code Mode', + icon: Code + } +] + +const ToggleXRay = () => { + const editorState = useEditor() + const isActive = Boolean(editorState.xray) + return ( + editorState.update({ ...editorState, xray: !isActive })} + disabled={editorState.mode === MODES[2].key} + /> + ) +} + +const Modes = () => { const editorState = useEditor() - console.log(editorState) + const [activeModeIndex, setActiveModeIndex] = useState(0) return ( -
{ + editorState.update({ ...editorState, mode: option.key }) + setActiveModeIndex(index) }} - > - - Blocks logo - - {editorState.mode === 'canvas' ? ( - { - editorState.update({ ...editorState, mode: 'code' }) - }} - > - - - ) : ( - { - editorState.update({ ...editorState, mode: 'canvas' }) - }} - > - - - )} -
+ /> ) } + +const Header = () => ( +
+ +
+ + +
+
+) + +export default Header + +const Logo = () => ( + + blocks logo +
+ Blocks + + v{version} + +
+
+) diff --git a/packages/blocks-ui/src/hooks.js b/packages/blocks-ui/src/hooks.js new file mode 100644 index 00000000..52c95969 --- /dev/null +++ b/packages/blocks-ui/src/hooks.js @@ -0,0 +1,59 @@ +import { useLayoutEffect, useRef } from 'react' +import interpolate from 'interpolate-range' + +export function useScrollSync(refs) { + const timeoutId = useRef() + const handleScroll = event => { + const targetRef = refs.current.filter( + ref => ref.contentDocument === event.target + )[0] + const syncedRefs = refs.current.filter( + ref => ref.contentDocument !== event.target + ) + const targetScrollTop = targetRef.contentWindow.pageYOffset + const targetMaxHeight = + targetRef.contentDocument.documentElement.scrollHeight - + targetRef.contentWindow.innerHeight + syncedRefs.forEach(ref => { + const { documentElement } = ref.contentDocument + removeEvent(ref.contentDocument) + documentElement.style.willChange = 'scroll-position' + const scrollY = interpolate({ + inputRange: [0, targetMaxHeight], + outputRange: [ + 0, + documentElement.scrollHeight - ref.contentWindow.innerHeight + ] + })(targetScrollTop) + ref.contentWindow.scrollTo(0, scrollY) + }) + timeoutId.current = setTimeout(() => { + syncedRefs.forEach(ref => { + addEvent(ref.contentDocument) + ref.contentDocument.documentElement.style.willChange = '' + }) + }, 120) + } + const addEvent = document => { + document.addEventListener('scroll', handleScroll, { + passive: true + }) + } + const removeEvent = document => { + document.removeEventListener('scroll', handleScroll, { + passive: true + }) + } + useLayoutEffect(() => { + const documents = refs.current + .map(ref => (ref ? ref.contentDocument : null)) + .filter(Boolean) + if (documents.length === 0) { + return + } + documents.forEach(addEvent) + return () => { + documents.forEach(removeEvent) + } + }) +} diff --git a/packages/blocks-ui/src/index.js b/packages/blocks-ui/src/index.js index c407c8d6..1b48a286 100644 --- a/packages/blocks-ui/src/index.js +++ b/packages/blocks-ui/src/index.js @@ -1,294 +1,5 @@ -/** @jsx jsx */ -import React, { useState, useEffect } from 'react' -import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' -import { Styled, jsx } from 'theme-ui' -import * as presets from '@theme-ui/presets' - -import * as DEFAULT_BLOCKS from '@blocks/blocks' - -import * as themeComponents from '@theme-ui/components' - -import * as transforms from './transforms' -import * as queries from './queries' - -import pragma from './pragma' - -import Header from './header' -import Canvas from './canvas' -import Layout from './layout' -import SidePanel from './side-panel' - -// blocks app theme -const appTheme = { - ...presets.system, - styles: { - ...presets.system.styles, - navlink: { - color: 'inherit', - textDecoration: 'none', - fontWeight: 600 - } - }, - buttons: { - primary: { - color: 'background', - bg: 'primary', - '&:hover': { - bg: 'text' - } - }, - secondary: { - color: 'text', - bg: 'background', - borderColor: 'text', - border: 'thin solid' - } - } -} - -const BLOCKS_Draggable = ({ active, children, ...props }) => { - return ( - - {(provided, snapshot) => - children( - { - ...provided, - draggableProps: { - ...provided.draggableProps, - css: { - boxShadow: active ? 'inset 0px 0px 0px 2px #0079FF' : undefined, - ':hover': { boxShadow: 'inset 0px 0px 0px 2px #bbbbbb' }, - '&:focus': { - outline: 'none', - boxShadow: 'inset 0px 0px 0px 1px #4d9ef7' - } - } - } - }, - snapshot - ) - } - - ) -} - -export default ({ src: initialCode, blocks: providedBlocks, onChange }) => { - const [code, setCode] = useState(null) - const [rawCode, setRawCode] = useState(null) - const [transformedCode, setTransformedCode] = useState(null) - const [elementId, setElementId] = useState(null) - const [elementData, setElementData] = useState(null) - const [activeTab, setActiveTab] = useState(0) - const [themeName, setThemeName] = useState('system') - const [srcBlocks, setSrcBlocks] = useState([]) - - const blocks = providedBlocks ? providedBlocks : DEFAULT_BLOCKS - const theme = presets[themeName] - - const scope = { - Blocks: DEFAULT_BLOCKS.Blocks, - Styled, - Link: Styled.a, - jsx: pragma(setElementId), - BLOCKS_Droppable: Droppable, - BLOCKS_Draggable: props => ( - - ), - BLOCKS_DraggableInner: props =>
, - BLOCKS_DroppableInner: props =>
, - BLOCKS_Text: props => , - ...themeComponents, - ...blocks - } - - useEffect(() => { - const newCode = transforms.addTuid(initialCode) - setCode(newCode) - }, []) - - useEffect(() => { - if (!elementId) { - return - } - - const newElementData = queries.getCurrentElement(code, elementId) - setElementData(newElementData) - - if (newElementData) { - setActiveTab(0) - } - }, [elementId, code]) - - useEffect(() => { - try { - const newTransformedCode = transforms.toTransformedJSX(code) - const newRawCode = transforms.toRawJSX(code) - const newSrcBlocks = queries.getBlocks(code) - - setRawCode(newRawCode) - setSrcBlocks(newSrcBlocks) - - if (newTransformedCode) { - setTransformedCode(newTransformedCode) - } - - if (newRawCode) { - onChange(newRawCode) - } - } catch (e) {} - }, [code]) - - if (!code || !transformedCode) { - return null - } - - const onDragEnd = drag => { - if (!drag.destination || drag.destination.droppableId === 'components') { - return - } - - if ( - drag.destination === 'root' && - drag.source.index === drag.destination.index - ) { - return - } - - if (drag.source.droppableId === 'components') { - const newCode = transforms.insertJSXBlock(code, { ...drag, blocks }) - setCode(newCode) - } else if (drag.source.droppableId.startsWith('element-')) { - console.log(drag) - } else { - const newCode = transforms.reorderJSXBlocks(code, drag) - setCode(newCode) - } - } - - const onBeforeDragStart = drag => { - setElementId(drag.draggableId) - } - - const handleRemove = key => () => { - const sx = elementData.props.sx || {} - delete sx[key] - - setElementData({ ...elementData, props: { ...elementData.props, sx } }) - - const { code: newCode } = transforms.removeSxProp(code, { elementId, key }) - setCode(newCode) - } - - const handleRemoveElement = () => { - const { code: newCode } = transforms.removeElement(code, { elementId }) - setCode(newCode) - if (elementData.parentId) { - setElementId(elementData.parentId) - } else { - setElementId(null) - } - setElementData(null) - } - - const handleClone = () => { - const { code: newCode } = transforms.cloneElement(code, { elementId }) - setCode(newCode) - } - - const handleInsertElement = () => { - const { code: newCode } = transforms.insertElementAfter(code, { elementId }) - setCode(newCode) - } - - const handleTextUpdate = e => { - const text = e.target.value - setElementData({ ...elementData, text }) - - const { code: newCode } = transforms.replaceText(code, { text, elementId }) - setCode(newCode) - } - - const handleChange = newSx => { - const sx = elementData.props.sx || {} - - setElementData({ - ...elementData, - props: { ...elementData.props, sx: { ...sx, ...newSx } } - }) - - const { code: newCode } = transforms.applySxProp(code, { - elementId, - sx: newSx - }) - - setCode(newCode) - } - - const handleParentSelect = () => { - if (elementData.parentId) { - setElementId(elementData.parentId) - } else { - setElementId(null) - setElementData(null) - } - } - - const handlePropChange = key => e => { - setElementData({ - ...elementData, - props: { ...elementData.props, [key]: e.target.value } - }) - - const { code: newCode } = transforms.applyProp(code, { - elementId, - key, - value: e.target.value - }) - - setCode(newCode) - } - - console.log(elementData) - - return ( - -
- -
- - -
-
- - ) -} +export { default } from './editor' +export * as Controls from './controls' +export { default as InlineRender } from './inline-render' +export * as queries from './queries' +export * as transforms from './transforms' diff --git a/packages/blocks-ui/src/inline-block-render.js b/packages/blocks-ui/src/inline-block-render.js index d1a3d1fe..6464c0ac 100644 --- a/packages/blocks-ui/src/inline-block-render.js +++ b/packages/blocks-ui/src/inline-block-render.js @@ -1,5 +1,6 @@ import React from 'react' import { jsx } from 'theme-ui' + import BlockError from './block-error' import { toTransformedBlockJSX } from './transforms' diff --git a/packages/blocks-ui/src/inline-render.js b/packages/blocks-ui/src/inline-render.js index 8e2cdc99..41014393 100644 --- a/packages/blocks-ui/src/inline-render.js +++ b/packages/blocks-ui/src/inline-render.js @@ -2,7 +2,9 @@ import React, { useMemo } from 'react' import { jsx, ThemeProvider, Styled } from 'theme-ui' -export default ({ code, scope, theme, ...props }) => { +import styles from './use-canvas-styles' + +export default ({ fullHeight, code, scope, theme, ...props }) => { const element = useMemo(() => { if (!code) { return null @@ -18,7 +20,8 @@ export default ({ code, scope, theme, ...props }) => { /* eslint-enable */ return fn(React, ...Object.values(scope)) - }, [code]) // TODO: Figure out why adding scope here breaks. + }, [code]) + // TODO: Figure out why adding scope here breaks. // I think it has to do with the inline render // definition of scope. @@ -27,8 +30,13 @@ export default ({ code, scope, theme, ...props }) => { div': { + height: '100%' + } }} > {element} diff --git a/packages/blocks-ui/src/layout.js b/packages/blocks-ui/src/layout.js index caeb8c11..173c8f82 100644 --- a/packages/blocks-ui/src/layout.js +++ b/packages/blocks-ui/src/layout.js @@ -1,30 +1,33 @@ -import React from 'react' -import { Styled, ThemeProvider } from 'theme-ui' +/** @jsx jsx */ +import { Styled, jsx } from 'theme-ui' import { Global } from '@emotion/core' -import { EditorProvider } from './editor-context' -import { ElementProvider } from './element-context' +import Header, { headerHeight } from './header' -export default ({ theme, elementData, children }) => { +export default ({ children }) => { return ( - - - - - - {children} - - - - + + +
+
+
{children}
+
+
) } diff --git a/packages/blocks-ui/src/loader.js b/packages/blocks-ui/src/loader.js new file mode 100644 index 00000000..7ff8c51d --- /dev/null +++ b/packages/blocks-ui/src/loader.js @@ -0,0 +1,166 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { keyframes } from '@emotion/core' + +const animationKeyframes = keyframes` + from { + transform: translate(0, -49px); + } + + 16.667% { + transform: translate(42px, -24px); + } + + 33.333% { + transform: translate(42px, 24px); + } + + 50% { + transform: translate(0, 49px); + } + + 66.667% { + transform: translate(-42px, 24px); + } + + 83.333% { + transform: translate(-42px, -24px); + } + + to { + translate(0, -49px); + } +` + +export const Loader = () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+) diff --git a/packages/blocks-ui/src/modes/canvas.js b/packages/blocks-ui/src/modes/canvas.js new file mode 100644 index 00000000..b070f096 --- /dev/null +++ b/packages/blocks-ui/src/modes/canvas.js @@ -0,0 +1,26 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' + +import { useCode } from '../providers/code' +import InlineRender from '../inline-render' +import { useScope } from '../providers/scope' + +import { CanvasWrap } from '../canvas' + +const CanvasMode = () => { + const { theme, ...scope } = useScope() + const { transformedCode } = useCode() + + return ( + + + + ) +} + +export default CanvasMode diff --git a/packages/blocks-ui/src/modes/code.js b/packages/blocks-ui/src/modes/code.js new file mode 100644 index 00000000..2a550eaf --- /dev/null +++ b/packages/blocks-ui/src/modes/code.js @@ -0,0 +1,41 @@ +/** @jsx jsx */ +import { useState, useEffect } from 'react' +import { jsx } from 'theme-ui' +import prettier from 'prettier/standalone' +import parserJS from 'prettier/parser-babel' + +import * as transforms from '../transforms' +import { useCode } from '../providers/code' +import { CanvasWrap } from '../canvas' +import { CodeEditor } from '../code-editor' + +const CodeMode = () => { + const { code, editCode } = useCode() + const [formattedCode, setFormattedCode] = useState('') + + useEffect(() => { + const rawCode = transforms.toRawJSX(code) + setFormattedCode( + prettier.format(rawCode, { + parser: 'babel', + plugins: [parserJS] + }) + ) + }, []) + + const onCodeChange = (code) => { + try { + editCode(code) + } catch (e) { + // do nothing as the errors are displayed in the editor + } + } + + return ( + + + + ) +} + +export default CodeMode diff --git a/packages/blocks-ui/src/modes/viewports.js b/packages/blocks-ui/src/modes/viewports.js new file mode 100644 index 00000000..806ba63a --- /dev/null +++ b/packages/blocks-ui/src/modes/viewports.js @@ -0,0 +1,25 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' + +import { useCode } from '../providers/code' +import { useScope } from '../providers/scope' + +import InlineRender from '../inline-render' +import { PreviewArea, Device } from '../device-preview' + +const ViewportsMode = () => { + const { theme, ...scope } = useScope() + const { transformedCode } = useCode() + + return ( + + {theme.breakpoints.map(breakpoint => ( + + + + ))} + + ) +} + +export default ViewportsMode diff --git a/packages/blocks-ui/src/panels/blocks-listing.js b/packages/blocks-ui/src/panels/blocks-listing.js new file mode 100644 index 00000000..6ae49d23 --- /dev/null +++ b/packages/blocks-ui/src/panels/blocks-listing.js @@ -0,0 +1,99 @@ +/** @jsx jsx */ +import { useMemo } from 'react' +import { Droppable, Draggable } from '@blocks/react-beautiful-dnd' +import { jsx, ThemeProvider } from 'theme-ui' +import { Box } from '@theme-ui/components' + +import InlineBlockRender from '../inline-block-render' +import { useEditor } from '../providers/editor' +import { useBlocks } from '../providers/blocks' +import { useScope } from '../providers/scope' +import { useCanvas } from '../providers/canvas' + +const isBlocksRoot = component => + component.Root && Object.keys(component).length === 1 + +export default () => { + const blocks = useBlocks() + const { theme } = useScope() + const { mode } = useEditor() + const { + canvasSize: { width } + } = useCanvas() + + const list = useMemo(() => { + return Object.keys(blocks).map((key, i) => { + const Component = blocks[key] + + // Ignore Blocks.Root since it's a special component + if (isBlocksRoot(Component)) { + return null + } + + return ( + + {(provided, _snapshot) => { + const Component = blocks[key] + // gets element the block is currently hovering over on drag + const currentlyHoveringOver = _snapshot.draggingOver + + return ( +
+
+ +
+
+ ) + }} +
+ ) + }) + }, [blocks, width]) + + if (mode === 'viewports') { + return ( +
+ Viewport mode does not work with components.
Switch to{' '} + canvas mode to add components. +
+ ) + } + + return ( + + + + {(provided, _snapshot) => ( +
+ {list} + {provided.placeholder} +
+ )} +
+
+
+ ) +} diff --git a/packages/blocks-ui/src/panels/editor.js b/packages/blocks-ui/src/panels/editor.js new file mode 100644 index 00000000..62c110b4 --- /dev/null +++ b/packages/blocks-ui/src/panels/editor.js @@ -0,0 +1,97 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { Flex } from '@theme-ui/components' +import { Trash, CornerRightUp, Copy, List } from 'react-feather' + +import { useCode } from '../providers/code' +import { useBlocks } from '../providers/blocks' +import { IconButton } from '../ui' + +import PropertyControlsPanel from './property-controls' + +export default () => { + const blocks = useBlocks() + const { + removeCurrentElement, + cloneCurrentElement, + updateProp, + updateSxProp, + insertText, + selectParentOfCurrentElement, + currentElementData: elementData + } = useCode() + + const keys = elementData.name.split('.') + + let block = blocks + keys.forEach(key => { + if (!block) { + return {} + } + + block = block[key] + }) + + const { propertyControls = {} } = block || {} + + return ( +
+ +

+ {elementData.name} +

+ +
+ +
+ ) +} diff --git a/packages/blocks-ui/src/panels/property-controls.js b/packages/blocks-ui/src/panels/property-controls.js new file mode 100644 index 00000000..2c72e359 --- /dev/null +++ b/packages/blocks-ui/src/panels/property-controls.js @@ -0,0 +1,159 @@ +/** @jsx jsx */ +import React from 'react' +import { jsx } from 'theme-ui' +import { Box, Label, Input, Select, Field, Radio } from '@theme-ui/components' +import { ControlType } from 'property-controls' + +import { FieldGroup } from '../field-group' +import { Sx } from '../theme-editor' +import { useThemeEditor } from '../providers/theme-editor' + +export default ({ + propertyControls = {}, + elementData, + onStyleChange, + onPropChange, + onTextChange +}) => { + const hasPropertyControls = Object.keys(propertyControls).length > 0 + const hasStyles = !!propertyControls.sx + const { update, ...theme } = useThemeEditor() + + return ( +
e.preventDefault()}> + {hasPropertyControls ? ( + + {Object.entries(propertyControls).map(([key, value]) => { + const title = value.title || key + let fieldValue = elementData.props[key] || value.defaultValue + if (fieldValue === undefined) { + fieldValue = null + } + if (value.type === ControlType.String && key === 'children') { + return ( +
+ + +
+ ) + } else if (value.type === ControlType.String) { + return ( +
+ + onPropChange(key, e)} + id={title} + /> +
+ ) + } else if (value.type === ControlType.Number) { + return ( +
+ + onPropChange(key, e, ControlType.Number)} + id={title} + /> +
+ ) + } else if (value.type === ControlType.Enum) { + return ( + onPropChange(key, e)} + as={Select} + > + {value.options.map(option => ( + + ))} + + ) + } else if (value.type === ControlType.Boolean) { + return ( +
+ + + + + +
+ ) + } else { + return null + } + })} +
+ ) : null} + {hasStyles ? ( + + + + + + + + + + + + + + + ) : null} +
+ ) +} diff --git a/packages/blocks-ui/src/panels/side.js b/packages/blocks-ui/src/panels/side.js new file mode 100644 index 00000000..ed686d6a --- /dev/null +++ b/packages/blocks-ui/src/panels/side.js @@ -0,0 +1,118 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@reach/tabs' + +import { useCode } from '../providers/code' +import { useEditor } from '../providers/editor' + +import EditorPanel from './editor' +import ThemePanel from './theme' +import BlocksListing from './blocks-listing' + +export default () => { + const { activeTab, activeTabIndex, updateActiveTab } = useEditor() + const { currentElementData: elementData } = useCode() + + return ( +
+ updateActiveTab(index)}> + + + Editor + + + Components + + + Theme + + + + + {elementData ? ( + + ) : ( +
+

+ Canvas +

+
+ )} +
+ + {activeTab === 'components' ? : null} + + + + +
+
+
+ ) +} + +const baseTabStyles = { + flex: 1, + appearance: 'none', + margin: 0, + py: 2, + fontSize: 0, + fontWeight: 500, + borderTop: 'none', + borderLeft: 'none', + borderRight: '1px solid', + borderBottom: '1px solid', + borderColor: 'border', + + '&:last-of-type': { + borderRight: 'none' + }, + + '&:focus': { + zIndex: 99, + outline: 'none', + fontWeight: 500, + textDecoration: 'underline' + } +} + +const activeTabStyles = { + borderBottomColor: 'white', + bg: 'white' +} + +const inactiveTabStyles = { + bg: '#fafafa' +} + +const getTabStyles = ({ isActive }) => ({ + ...baseTabStyles, + ...(isActive ? activeTabStyles : inactiveTabStyles) +}) diff --git a/packages/blocks-ui/src/panels/theme.js b/packages/blocks-ui/src/panels/theme.js new file mode 100644 index 00000000..dfc27245 --- /dev/null +++ b/packages/blocks-ui/src/panels/theme.js @@ -0,0 +1,92 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import { Fragment, useState } from 'react' +import * as presets from '@theme-ui/presets' +import { Label, Select, Button } from '@theme-ui/components' + +import { Theme as BlocksThemeEditor, EditorProvider } from '../theme-editor' +import useCopyToClipboard from '../use-copy-to-clipboard' +import { useThemeEditor } from '../providers/theme-editor' +import { FieldGroup } from '../field-group' + +const themes = Object.keys(presets) +const options = themes.map(name =>
-) - -export const HeaderAAA = () => ( -
- - Hello - - - -
-) - -export const HeaderAAAA = () => ( -
- - -
-) - -export const HeaderB = () => ( -
-
- - Hello - -
- - Blog - - - About - -
-
-) - -export const HeaderBA = () => ( -
-
- - Hello - -
- - Blog - - - About - -
-
-) - -export const HeaderBB = () => ( -
-
- - Hello - -
- - Blog - - - About - -
-
-) - -export const HeaderBC = () => ( -
-
- - Hello - -
- - Blog - - - About - -
-
-) - -export const HeaderC = () => ( -
-
- - UI Logo - - Home - - -
-
- - Work - - - Blog - -
-
- - About - - - Contact - -
-
-) - -export const HeaderC2 = () => ( -
-
- - UI Logo - - Home - - -
-
- - Work - - - Blog - -
-
- - About - - - Contact - -
-
-) - -export const HeaderC3 = () => ( -
-
- - UI Logo - - Home - - -
-
- - Work - - - Blog - -
-
- - About - - - Contact - -
-
-) - -export const HeaderD = () => ( -
- -
- - Home - -
-
- - Blog - - - About - -
-
-) - -export const HeaderF = () => ( -
-
- - UI Logo - - Home - - -
-
- - Work - - - Blog - - - About - - - Contact - -
-
-) - -export const HeaderFA = () => ( -
-
- - UI Logo - - Home - - -
-
- - Work - - - Blog - - - About - - - Contact - -
-
-) - -export const HeaderFB = () => ( -
-
- - UI Logo - - Home - - -
-
- - -
-
-) - -export const HeaderE = () => ( -
- - UI Logo - - -
-) - -export const HeaderE2 = () => ( -
- - UI Logo - - -
-) - -export const HeaderG = () => ( -
- - UI Logo - - -
-) - -export const HeaderH = () => ( -
- - Blocks - - - Blog - - - About - - - Contact - -
-) - -export const HeaderI = () => ( -
- - Blocks - - - Blog - - - About - - - Contact - -
-) - -export const FooterA = () => ( -
- - Home - - - Blog - - - About - -
-
Β© 2019 Jane Doe
-
-) - -export const FooterB = () => ( -
-
- - Home - - - Blog - - - About - -
-
Β© 2019 Jane Doe
-
-
-) - -export const FooterC = () => ( -
- - Home - - - Blog - - - About - -
-) - -export const FooterD = () => ( -
-
- - Home - - - Work - - - Blog - - - About - - - Products - - - Community - - - Support - - - Contact - - - Support - - - Products - - - Contact - - - Community - - - Products - - - Community - - - Support - - - Contact - -
-
- - Privacy Policy - -
- - Terms of Use - -
Β© 2019 Jane Doe -
-
-) - -export const FooterE = () => ( -
- - hello@example.org - -

Β© 2019 Jane Doe

-
- - Privacy Policy - -
- - Terms of Use - -
-
-) - -export const ImageTitleA = () => ( -
-

- Hello, world! -

-
-) - -export const ImageTitleB = () => ( -
-

- Hello, world! -

-
-) - -export const ImageTitleC = () => ( -
-

- Hello, world! -

-
-) - -export const TagLineA = () => ( - - - - What is a block? - - - A Block refers to a piece of content or a component. It's a section of - your content while a document is a collection of blocks. - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. Blocks can be complex like an embedded - spreadsheet or a chart that fetches live data. - - - -) - -export const TagLineB = () => ( - - - - What is a block? - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. Blocks can be complex like an embedded - spreadsheet or a chart that fetches live data. - - - - -) - -export const TagLineC = () => ( - - - - What is a block? - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - - - - -) - -export const TagLineD = () => ( - - - - - - - What is a block? - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - -) - -export const TagLineE = () => ( - - - What is a block? - - - Blocks can be simple like a paragraph of text or even a box with a tomato - background color. - - - -) - -export const TagLineEA = () => ( - - - - Let's do something great together - - - - -) - -export const TagLineF = () => ( - - - - What is a block? - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - - -) - -export const TagLineFA = () => ( - - - - What is a block? - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - - -) - -export const HeroA = () => ( - - - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - - - - - - Hologram graphic - - -) - -export const HeroB = () => ( - - - Kicker - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - - - - - - Hologram graphic - - -) - -export const HeroBG = () => ( - - - - - Kicker - - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - - - - - Hologram graphic - - - -) - -export const HeroBGNewsletter = () => ( - - - - - Coming Soon - - Some product - - This is a hero, with some convincing copy and a call to action. - - - Sign up for project updates and releases. - - - - - - - - Hologram graphic - - - -) - -export const HeroC = () => ( - - - Hologram graphic - - - Kicker - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - - - - - -) - -export const HeroD = () => ( - - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - - or - - - -) - -export const HeroE = () => ( - - - Hello, world! - - This is a hero, with some convincing copy and a rad picture of the top - of a building. - - - - - - -) - -export const HeroEA = () => ( - - - An image overlap - - Having your written content overlap the image on the side is playful. It - gives a unique, modern feel to your site. - - - It's a nice touch for minimal websites and stores. - - - - - - -) - -export const HeroEB = () => ( - - - - - - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - This is a hero, with some convincing copy and a call to action. - - - -) - -export const HeroF = () => ( - - Awesomeness - - This is a hero, with some convincing copy and a call to action. - - - - - - -) - -export const HeroFA = () => ( - - - - - Demo content is interesting - - - It often shows houses that are way cleaner than my own. How do those - blankets stay folded? I guess they don't have kids. - - - - - - - - - - -) - -export const HeroG = () => ( - - - - Awesomeness - - - This is a hero, with some convincing copy and a call to action. - - - - - - - -) - -export const DescriptionA = () => ( - - - - It's pretty fun to turn JSX into a WYSIWYG. - -
— Me
-
- - - Blocks can be simple like a paragraph of text or a box with a tomato - background color. They can be complex and contain state or even fetch - data. They're pre-built components that can be drag and dropped onto the - canvas. - - - It's β€œlow code” because you don't have to peek into the JSX until you - have bespoke needs. - - - Blocks are typically composed of multiple components which allows you to - edit styling of a particular element. - - - - - -
-) - -export const DescriptionAA = () => ( - - - - This is an amazing quote about something very interesting. - -
— Some person
-
- - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. Blocks can be simple like a paragraph of text - or even a box with a tomato background color. - - Blocks can be simple. - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - - - -
-) - -export const DescriptionAAA = () => ( - - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. Blocks can be simple like a paragraph of text - or even a box with a tomato background color. - - Blocks can be simple. - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - Blocks can be simple like a paragraph of text or even a box with a - tomato background color. - - - - - - - - This is an amazing quote about something very interesting. - -
— Some person
-
-
-) - -export const BlockQuote = () => ( - - - This is an amazing quote about something very interesting. - -
— Some person
-
-) - -export const ImageBlock = () => ( -
- Pine branch -
-) - -/* -export const ImageGrid = () => ( - - -
- Pine branch -
-
- -
- Pine branch -
-
- -
- Pine branch -
-
- -
- Pine branch -
-
- -
- Pine branch -
-
- -
- Pine branch -
-
-
-) - -export const ImageTextGrid = () => ( - - -
- Pine branch -
- Hello, world! - - Here is some text about things, more things, and yet other things. - -
- -
- Pine branch -
- Hello, world! - - Here is some text about things, more things, and yet other things. - -
- -
- Pine branch -
- Hello, world! - - Here is some text about things, more things, and yet other things. - -
-
-) - -export const ImageTextGridA = () => ( - - -
- Pine branch -
- Hello, world! - - Here is some text about things, more things, and yet other things. - -
- -
- Pine branch -
- Hello, world! - - Here is some text about things, more things, and yet other things. - -
- -
- Pine branch -
- Hello, world! - - Here is some text about things, more things, and yet other things. - -
-
-) -*/ - -export const H1 = () => ( -
- Heading One -
-) -export const H2 = () => ( -
- Heading Two -
-) -export const H3 = () => ( -
- Heading Three -
-) -export const H4 = () => ( -
- Heading Four -
-) -export const P = () => ( -
- Here's an awesome paragraph -
-) diff --git a/packages/blocks-ui/src/segmented-control.js b/packages/blocks-ui/src/segmented-control.js new file mode 100644 index 00000000..ce2f77fc --- /dev/null +++ b/packages/blocks-ui/src/segmented-control.js @@ -0,0 +1,37 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' + +import { IconButton } from './ui' + +export const SegmentedControl = ({ + options, + activeIndex, + onChange, + ...rest +}) => { + return ( +
+ {options.map((option, index) => ( + onChange(option, index)} + /> + ))} +
+ ) +} diff --git a/packages/blocks-ui/src/side-panel.js b/packages/blocks-ui/src/side-panel.js deleted file mode 100644 index cb5b6a62..00000000 --- a/packages/blocks-ui/src/side-panel.js +++ /dev/null @@ -1,158 +0,0 @@ -/** @jsx jsx */ -import React from 'react' -import { jsx } from 'theme-ui' -import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@reach/tabs' - -import EditorPanel from './editor-panel' -import ThemePanel from './theme-panel' -import BlocksListing from './blocks-listing' -import TreeView from './tree-view' - -export default ({ - activeTab, - setActiveTab, - blocks, - srcBlocks, - theme, - themeName, - elementData, - handleChange, - handlePropChange, - handleRemove, - handleRemoveElement, - handleParentSelect, - handleInsertElement, - handleClone, - handleTextUpdate, - setThemeName, - setElementId -}) => ( -
- setActiveTab(index)}> - - - Editor - - - Components - - - Theme - - - - - {elementData ? ( - - ) : ( -
-

- Canvas -

- -
- )} -
- - {activeTab === 1 ? ( - - ) : null} - - - - -
-
-
-) diff --git a/packages/blocks-ui/src/theme-editor/ColorPicker.js b/packages/blocks-ui/src/theme-editor/ColorPicker.js new file mode 100644 index 00000000..43da65aa --- /dev/null +++ b/packages/blocks-ui/src/theme-editor/ColorPicker.js @@ -0,0 +1,192 @@ +/** @jsx jsx */ +import { jsx } from 'theme-ui' +import React from 'react' +import { CustomPicker } from 'react-color' +import { + Hue, + Saturation, + EditableInput, +} from 'react-color/lib/components/common' +import { usePopoverState, Popover, PopoverDisclosure } from 'reakit/Popover' + +const round = (n, x = 0) => Math.floor(n * Math.pow(10, x)) / Math.pow(10, x) + +const Lens = props => ( +
+) + +const Handle = props => ( +
+) + +/** placeholder is used because react-color does not pass + * HTML attributes to the element + */ +const Input = props => ( + +) + +const Label = ({ width = '100%', flex = 1, ...props }) => ( +