├── .gitignore ├── LICENSE ├── README.md ├── manipulative.gif ├── package.json ├── rollup.config.js ├── src ├── babel.ts ├── client.ts ├── client │ ├── Pane.tsx │ └── index.tsx ├── macro.ts ├── plugin │ └── index.ts ├── server.ts └── server │ └── index.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Paul Shen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # manipulative 2 | 3 | A React devtool for live-updating [Emotion](https://emotion.sh/) styles in the browser. When the styles look good, write them to your source files with one click. 4 | 5 | ![manipulative demo](./manipulative.gif) 6 | 7 | > manipulative is currently alpha-quality software. If manipulative is not working for your use case, please file an issue and I'll try my best to help. 8 | 9 | ## Requirements 10 | 11 | - You're using `@emotion/react` with [`css` prop](https://emotion.sh/docs/css-prop) 12 | - You're using React Fast Refresh (included w/ [create-react-app](https://create-react-app.dev/) 4+) 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install --dev manipulative 18 | # or 19 | yarn add --dev manipulative 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### Run server 25 | 26 | The server writes changes to your source files. 27 | 28 | ```sh 29 | npx manipulative-server 30 | ``` 31 | 32 | ### Invoke manipulative 33 | 34 | Use one of these two approaches. 35 | 36 | 1. `useCssPlaceholder()` - quickest but not ideal 37 | 38 | If you have a create-react-app, you can use the Babel macro without any setup. Add calls to `useCssPlaceholder()` on elements you want to style. 39 | 40 | ```js 41 | import { useCssPlaceholder } from "manipulative/macro"; 42 | 43 | function MyComponent() { 44 | return ( 45 |
46 |

...

47 |
48 | ); 49 | } 50 | ``` 51 | 52 | 2. `css__` prop 53 | 54 | This more convenient approach requires a little Babel setup ([see below](#recommended-babel-setup)). 55 | 56 | ```js 57 | // no need to import anything 58 | function MyComponent() { 59 | return ( 60 |
61 |

...

62 |
63 | ); 64 | } 65 | ``` 66 | 67 | ### Modify and commit styles 68 | 69 | In the browser, you should see the manipulative inspector with an input for each `useCssPlaceholder()` or `css__` prop. Type CSS in the textarea to see styles update live. Click "commit" to write changes back to the source files, replacing `useCssPlaceholder()` and `css__` props. 70 | 71 | Be sure to remove any imports from `manipulative` when building for production! 72 | 73 | ## Recommended Babel setup 74 | 75 | If you want to use the more convenient `css__` syntax, you'll need to install a Babel plugin that runs before React Fast Refresh. 76 | 77 | If you have access to the Webpack config (e.g. you ejected CRA), add `manipulative/babel` to the list of Babel plugins. This plugin needs to run before `react-refresh/babel`. 78 | 79 | ``` 80 | { 81 | loader: 'babel-loader', 82 | plugins: [ 83 | 'manipulative/babel', 84 | 'react-refresh/babel', 85 | ], 86 | ... 87 | } 88 | ``` 89 | 90 | If you have not ejected CRA, you can still use this plugin with something like [react-app-rewired](https://github.com/timarney/react-app-rewired). Here is an example `config-overrides.js` with `react-app-rewired`. 91 | 92 | ```js 93 | const { getBabelLoader } = require("customize-cra"); 94 | 95 | module.exports = function override(config) { 96 | getBabelLoader(config).options.plugins.unshift( 97 | require.resolve("manipulative/babel") 98 | ); 99 | return config; 100 | }; 101 | ``` 102 | 103 | ## Known Limitations 104 | 105 | - manipulative only supports static styles. It does not handle functions or JS variables. 106 | - `css__` cannot be used with `css` prop on the same element 107 | - `css__` is transformed to `css={...}`. Therefore, one will override the other. There may be support for modifying existing styles in the future. 108 | -------------------------------------------------------------------------------- /manipulative.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulshen/manipulative/9e85101948f27b208f6fb95c08b86847e4bf8007/manipulative.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "manipulative", 3 | "description": "React devtool for modifying Emotion styles in browser", 4 | "version": "0.1.1", 5 | "main": "client.cjs.js", 6 | "module": "client.js", 7 | "homepage": "https://github.com/paulshen/manipulative#readme", 8 | "repository": "github:paulshen/manipulative", 9 | "bin": { 10 | "manipulative-server": "server.js" 11 | }, 12 | "types": "types/client.d.ts", 13 | "scripts": { 14 | "prebuild": "rimraf dist", 15 | "build": "rollup -c && yarn ts-declarations", 16 | "postbuild": "yarn copy", 17 | "ts-declarations": "tsc --emitDeclarationOnly --declarationDir dist/types --declaration true", 18 | "copy": "cp dist/types/client.d.ts dist/types/macro.d.ts && copyfiles -f package.json README.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined;\"" 19 | }, 20 | "devDependencies": { 21 | "@babel/plugin-transform-react-jsx": "^7.12.7", 22 | "@babel/plugin-transform-regenerator": "^7.12.1", 23 | "@babel/plugin-transform-runtime": "^7.12.1", 24 | "@babel/plugin-transform-typescript": "^7.12.1", 25 | "@babel/preset-env": "^7.12.7", 26 | "@emotion/babel-preset-css-prop": "^11.0.0", 27 | "@emotion/react": "^11.1.1", 28 | "@rollup/plugin-babel": "^5.2.1", 29 | "@rollup/plugin-node-resolve": "^10.0.0", 30 | "@rollup/plugin-typescript": "^6.1.0", 31 | "@types/babel-plugin-macros": "^2.8.4", 32 | "@types/babel__core": "^7.1.12", 33 | "@types/body-parser": "^1.19.0", 34 | "@types/cors": "^2.8.8", 35 | "@types/express": "^4.17.9", 36 | "@types/node": "^14.14.10", 37 | "@types/prettier": "^2.1.5", 38 | "@types/react": "^17.0.0", 39 | "@types/react-dom": "^17.0.0", 40 | "copyfiles": "^2.4.1", 41 | "json": "^10.0.0", 42 | "rimraf": "^3.0.2", 43 | "rollup": "^2.33.3", 44 | "rollup-plugin-executable": "^1.6.1", 45 | "tslib": "^2.0.3", 46 | "typescript": "^4.1.2" 47 | }, 48 | "dependencies": { 49 | "@babel/core": "^7.12.9", 50 | "@babel/types": "^7.12.7", 51 | "babel-plugin-macros": "^3.0.0", 52 | "body-parser": "^1.19.0", 53 | "commander": "^6.2.0", 54 | "cors": "^2.8.5", 55 | "express": "^4.17.1", 56 | "prettier": "^2.2.0", 57 | "zustand": "^3.2.0" 58 | }, 59 | "peerDependencies": { 60 | "@emotion/react": "11.x", 61 | "react": "17.x", 62 | "react-dom": "17.x" 63 | }, 64 | "author": "Paul Shen", 65 | "license": "MIT", 66 | "keywords": [ 67 | "react", 68 | "devtool", 69 | "emotion", 70 | "style" 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import typescript from "@rollup/plugin-typescript"; 4 | import path from "path"; 5 | import executable from "rollup-plugin-executable"; 6 | 7 | const { root } = path.parse(process.cwd()); 8 | const external = (id) => 9 | !id.startsWith(".") && !id.startsWith(root) && id !== "tslib"; 10 | const extensions = [".js", ".ts", ".tsx"]; 11 | const getBabelOptions = (targets) => { 12 | const config = { 13 | ignore: ["./node_modules"], 14 | presets: [ 15 | [ 16 | "@babel/preset-env", 17 | { 18 | loose: true, 19 | targets, 20 | }, 21 | ], 22 | ["@emotion/babel-preset-css-prop", { sourceMap: false }], 23 | ], 24 | plugins: [ 25 | [ 26 | "@babel/plugin-transform-react-jsx", 27 | { 28 | runtime: "automatic", 29 | importSource: "@emotion/react", 30 | }, 31 | ], 32 | ["@babel/plugin-transform-typescript", { isTSX: true }], 33 | ], 34 | babelHelpers: "bundled", 35 | sourceMaps: false, 36 | }; 37 | if (targets.ie) { 38 | config.plugins = [ 39 | ...config.plugins, 40 | "@babel/plugin-transform-regenerator", 41 | ["@babel/plugin-transform-runtime", { helpers: true, regenerator: true }], 42 | ]; 43 | config.babelHelpers = "runtime"; 44 | } 45 | return { 46 | ...config, 47 | extensions, 48 | }; 49 | }; 50 | 51 | function createESMConfig(input, output) { 52 | return { 53 | input, 54 | output: { file: output, format: "esm" }, 55 | external, 56 | plugins: [ 57 | resolve({ extensions }), 58 | babel(getBabelOptions({ node: 8 })), 59 | typescript(), 60 | ], 61 | }; 62 | } 63 | 64 | function createCommonJSExecutableConfig(input, output) { 65 | return { 66 | input, 67 | output: { 68 | file: output, 69 | format: "cjs", 70 | banner: "#!/usr/bin/env node", 71 | }, 72 | external, 73 | plugins: [ 74 | resolve({ extensions }), 75 | babel(getBabelOptions({ node: 8 })), 76 | typescript(), 77 | executable(), 78 | ], 79 | }; 80 | } 81 | 82 | function createCommonJSConfig(input, output) { 83 | return { 84 | input, 85 | output: { file: output, format: "cjs", exports: "named" }, 86 | external, 87 | plugins: [ 88 | resolve({ extensions }), 89 | babel(getBabelOptions({ ie: 11 })), 90 | typescript(), 91 | ], 92 | }; 93 | } 94 | 95 | export default [ 96 | createESMConfig("src/client.ts", "dist/client.js"), 97 | createCommonJSConfig("src/client.ts", "dist/client.cjs.js"), 98 | createCommonJSExecutableConfig("src/server.ts", "dist/server.js"), 99 | createCommonJSConfig("src/babel.ts", "dist/babel.js"), 100 | createCommonJSConfig("src/macro.ts", "dist/macro.js"), 101 | ]; 102 | -------------------------------------------------------------------------------- /src/babel.ts: -------------------------------------------------------------------------------- 1 | import { babelPlugin } from "./plugin/index"; 2 | module.exports = babelPlugin; 3 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | export * from "./client/index"; 2 | -------------------------------------------------------------------------------- /src/client/Pane.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import { css } from "@emotion/react"; 3 | 4 | function Pane({ children }: { children: React.ReactNode }) { 5 | const rootRef = useRef(null); 6 | const headerRef = useRef(null); 7 | const offsetRef = useRef<[x: number, y: number]>([0, 0]); 8 | const onUnmountRef = useRef(); 9 | 10 | function onHeaderMouseDown(e: React.MouseEvent) { 11 | let lastPosition = [e.nativeEvent.screenX, e.nativeEvent.screenY]; 12 | function onMouseMove(e: MouseEvent) { 13 | const [lastX, lastY] = lastPosition; 14 | const deltaX = e.screenX - lastX; 15 | const deltaY = e.screenY - lastY; 16 | offsetRef.current[0] += deltaX; 17 | offsetRef.current[1] += deltaY; 18 | rootRef.current!.style.transform = `translate3d(${offsetRef.current[0]}px, ${offsetRef.current[1]}px, 0)`; 19 | lastPosition = [e.screenX, e.screenY]; 20 | } 21 | function cleanup() { 22 | window.removeEventListener("mousemove", onMouseMove); 23 | window.removeEventListener("mouseup", onMouseUp); 24 | } 25 | function onMouseUp() { 26 | cleanup(); 27 | } 28 | window.addEventListener("mousemove", onMouseMove); 29 | window.addEventListener("mouseup", onMouseUp); 30 | onUnmountRef.current = cleanup; 31 | } 32 | 33 | useEffect(() => { 34 | return () => { 35 | if (onUnmountRef.current !== undefined) { 36 | onUnmountRef.current(); 37 | } 38 | }; 39 | }, []); 40 | 41 | return ( 42 |
64 |
78 | manipulative 79 |
80 |
{children}
81 |
82 | ); 83 | } 84 | 85 | export default Pane; 86 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import {} from "@emotion/react/types/css-prop"; 3 | import React, { useEffect, useState } from "react"; 4 | import ReactDOM from "react-dom"; 5 | import create from "zustand"; 6 | import Pane from "./Pane"; 7 | 8 | type CallsiteValue = { 9 | value: string; 10 | hover: boolean; 11 | lineNumber: number | undefined; 12 | codeLine: string | undefined; 13 | }; 14 | const useStore = create<{ 15 | callsites: Record; 16 | updateCallsite: (location: string, value: CallsiteValue) => void; 17 | removeCallsite: (location: string) => void; 18 | }>((set) => ({ 19 | callsites: {}, 20 | updateCallsite: (location, value) => 21 | set((state) => ({ 22 | ...state, 23 | callsites: { ...state.callsites, [location]: value }, 24 | })), 25 | removeCallsite: (location) => 26 | set((state) => { 27 | const newCallsites = { ...state.callsites }; 28 | delete newCallsites[location]; 29 | return { 30 | ...state, 31 | callsites: newCallsites, 32 | }; 33 | }), 34 | })); 35 | 36 | type CommitState = { type: "committing" } | { type: "error"; error: string }; 37 | 38 | function Inspector() { 39 | const { callsites, updateCallsite } = useStore(); 40 | const [commitState, setCommitState] = useState(); 41 | useEffect(() => { 42 | // clear commit state on fast refresh 43 | return () => setCommitState(undefined); 44 | }, []); 45 | if (Object.keys(callsites).length === 0) { 46 | return null; 47 | } 48 | return ( 49 | 50 |
70 | {Object.keys(callsites).map((location) => { 71 | const callsite = callsites[location]; 72 | const [filePath, position] = location.split(":"); 73 | const fileName = filePath.substring(filePath.lastIndexOf("/") + 1); 74 | return ( 75 |
81 | {callsite.codeLine !== undefined ? ( 82 |
{ 84 | updateCallsite(location, { ...callsite, hover: true }); 85 | }} 86 | onMouseOut={() => { 87 | updateCallsite(location, { ...callsite, hover: false }); 88 | }} 89 | css={css` 90 | font-size: 12px; 91 | margin-bottom: 4px; 92 | overflow: hidden; 93 | white-space: nowrap; 94 | text-overflow: ellipsis; 95 | cursor: default; 96 | `} 97 | > 98 | {callsite.codeLine} 99 |
100 | ) : null} 101 |
102 |