├── .gitignore
├── README.md
├── package.json
├── public
└── index.html
├── src
├── App.tsx
├── controls.json
├── controls.tsx
├── index.tsx
├── react-app-env.d.ts
├── react-handoff-chakra
│ ├── badge.tsx
│ ├── box.tsx
│ ├── constants.ts
│ ├── image.tsx
│ ├── index.ts
│ └── init.ts
├── react-handoff
│ ├── dimensions.ts
│ ├── fields.tsx
│ ├── index.ts
│ └── react-handoff.tsx
└── styles.css
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .vscode
3 | .DS_Store
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-handoff
2 |
3 | This is a react library POC that bakes into any app a minimal visual editor that enables designers and developers to work in parallel on production code.
4 |
5 | ## In a nutshell
6 | With this library, **this**:
7 | ```tsx
8 | function App() {
9 | return (
10 |
11 |
18 |
25 |
26 |
27 |
28 | New
29 |
30 |
39 | {property.beds} beds • {property.baths} baths
40 |
41 |
42 |
43 |
52 | {property.title}
53 |
54 |
55 |
56 | {property.formattedPrice}
57 |
58 | / wk
59 |
60 |
61 |
62 |
70 | {property.reviewCount} reviews
71 |
72 |
73 |
74 |
75 | );
76 | }
77 | ```
78 | will give you **[this](https://codesandbox.io/s/async-night-7kyrq?file=/src/App.tsx)**:
79 |
80 | 
81 |
82 |
83 | ## How does it work?
84 | This app works similar to the way that theme providers work. By centralizing prop defaults and overrides in a json file and providing them across the app, we can allow components to consume the values they need from the configuration via designated keys. In this demo, `controls.json`
85 | contains all of the values configured via the visual editor.
86 |
87 | To set up the code, we first need to call `init`, passing in our defaults and an indication of the environment we're running in (because we don't want editor functionality to show up in prod).
88 |
89 | ```tsx
90 | import { init } from "./react-handoff";
91 | import defaults from "./controls.json";
92 |
93 | const { createControls, ControlsProvider } = init(defaults, {
94 | allowEditing: process.env.NODE_ENV === "development"
95 | });
96 | ```
97 |
98 | Once, we've initialized, we need to wrap our app in the `ControlsProvider`.
99 |
100 | ```tsx
101 |
102 |
103 |
104 | ```
105 |
106 | The `ControlsProvider` allows the `controls.json` values to be accessed anywhere in the app. The `ControlsProvider` also renders the editor sidebar and the active element indicator.
107 |
108 | Next we can use `createControls` to define our editors input fields (similar to the way storybook knobs work).
109 |
110 | ```tsx
111 | import { select } from "./react-handoff/fields";
112 |
113 | const useControls = createControls({
114 | key: "Image",
115 | definitions: {
116 | objectFit: select(["fill", "contain", "cover"])
117 | }
118 | });
119 | ```
120 |
121 | This may seem mysterious, but `select` actually just returns a react component, so you can pass any key/val pair as a definition as long as the key is a string and the value is a component that adheres to the following component interface.
122 |
123 | ```tsx
124 | type Field = ComponentType<{
125 | value: T;
126 | onUpdate: (value: T) => void;
127 | }>
128 | ```
129 |
130 | Lastly, we have to consume our `useControls` hook.
131 |
132 | ```tsx
133 | interface ControlledImageProps extends ImageProps {
134 | controlsKey: string;
135 | }
136 |
137 | const ControlledImage: FC = ({
138 | controlsKey,
139 | objectFit,
140 | ...otherProps
141 | }) => {
142 | const { attach, values } = useControls({
143 | subkey: controlsKey,
144 | passthrough: {
145 | objectFit
146 | }
147 | });
148 |
149 | return (
150 |
155 | );
156 | };
157 | ```
158 |
159 | An instance of the `ControlledImage` component would look for the key: `'Image'` and subkey: `controlsKey` in the `controls.json`.
160 |
161 | ```tsx
162 | // This would have access to values located at
163 | // controlsJson['Image']['card-image']
164 |
165 | ```
166 |
167 | Internally, the `ControlledImage` component has access to the `values` and `overrides` that live at this location in the `controls.json`.
168 |
169 | 
170 |
171 |
172 | If the passthrough value for `objectFit` is defined when invoking `useControls`, then that value will be passed through to the returned `values` object. However, if the passthrough value for `objectFit` is `undefined` then the value located at `controlsJson['Image']['card-image'].values.objectFit` will be used as a default value. Furthermore, even if the passthrough value for `objectFit` is defined, it can be overriden by checking the "Important" checkbox via the editor controls.
173 |
174 | The `attach` method returned from `useControls` takes in an `HTMLElement` and listens to resize/click events on it. Every time a resize happens, the new dimensions of the active element are propagated to the globally rendered active element indicator (that dashed teal box). This way we can observe the app without polluting it with extra layers of dom crap.
175 |
176 | I've only built out a few controls options on a few chakra-ui components, but you can see how it could be extended to all of chakra-ui and other component libraries/custom components as well.
177 |
178 | This pattern allows developers to develop and designers to design all at once with all of the power of libraries such as chakra-ui.
179 |
180 | ## Running locally
181 |
182 | ```bash
183 | yarn install
184 | yarn start
185 | ```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chakra-ui-typescript",
3 | "version": "1.0.0",
4 | "description": "",
5 | "keywords": [],
6 | "main": "src/index.tsx",
7 | "dependencies": {
8 | "@chakra-ui/core": "0.7.0",
9 | "@emotion/core": "10.0.28",
10 | "@emotion/styled": "10.0.27",
11 | "@types/file-saver": "^2.0.1",
12 | "@types/react": "16.9.33",
13 | "@types/recoil": "^0.0.1",
14 | "emotion-theming": "10.0.27",
15 | "file-saver": "^2.0.2",
16 | "framer-motion": "^2.4.0",
17 | "react": "16.13.1",
18 | "react-dom": "16.13.1",
19 | "react-scripts": "3.4.1",
20 | "recoil": "^0.0.10",
21 | "resize-observer-polyfill": "^1.5.1"
22 | },
23 | "devDependencies": {
24 | "@types/react": "16.8.8",
25 | "@types/react-dom": "16.8.2",
26 | "typescript": "^3.9.7"
27 | },
28 | "scripts": {
29 | "start": "react-scripts start",
30 | "build": "react-scripts build",
31 | "test": "react-scripts test --env=jsdom",
32 | "eject": "react-scripts eject"
33 | },
34 | "browserslist": [
35 | ">0.2%",
36 | "not dead",
37 | "not ie <= 11",
38 | "not op_mini all"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
23 | React App
24 |
25 |
26 |
27 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Flex } from "@chakra-ui/core";
3 | import { components } from "./controls";
4 | const { Box, Image, Badge } = components;
5 |
6 | // The app's kind of ugly, so you might want to fix it with
7 | // the visual editor controls. Once it looks good, click download
8 | // and replace the controls.json in the root of this repo with
9 | // the downloaded one to persist your changes.
10 |
11 | const property = {
12 | imageUrl: "https://picsum.photos/id/594/600/400",
13 | imageAlt: "Home on the water",
14 | beds: 3,
15 | baths: 2,
16 | title: "Humble home on the water",
17 | formattedPrice: "$500.00",
18 | reviewCount: 34
19 | };
20 |
21 | function App() {
22 | return (
23 |
24 |
31 |
38 |
39 |
40 |
41 | New
42 |
43 |
52 | {property.beds} beds • {property.baths} baths
53 |
54 |
55 |
56 |
65 | {property.title}
66 |
67 |
68 |
69 | {property.formattedPrice}
70 |
71 | / wk
72 |
73 |
74 |
82 | {property.reviewCount} reviews
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
90 | export default App;
91 |
--------------------------------------------------------------------------------
/src/controls.json:
--------------------------------------------------------------------------------
1 | {
2 | "Badge": {
3 | "badge": {
4 | "values": {
5 | "fontSize": "sm",
6 | "variantColor": "yellow"
7 | },
8 | "overrides": {}
9 | }
10 | },
11 | "Box": {
12 | "card": {
13 | "values": {
14 | "bg": "pink.600"
15 | },
16 | "overrides": {
17 | "bg": true
18 | }
19 | },
20 | "beds-and-baths": {
21 | "values": {
22 | "color": "gray.100"
23 | },
24 | "overrides": {
25 | "color": true
26 | }
27 | },
28 | "property-title": {
29 | "values": {
30 | "color": "gray.50",
31 | "fontSize": "xs"
32 | },
33 | "overrides": {
34 | "fontSize": true
35 | }
36 | },
37 | "price": {
38 | "values": {
39 | "color": "gray.200",
40 | "fontSize": "3xl"
41 | },
42 | "overrides": {
43 | "color": false,
44 | "fontSize": true
45 | }
46 | },
47 | "reviews-count": {
48 | "values": {
49 | "color": "purple.900"
50 | },
51 | "overrides": {
52 | "color": true
53 | }
54 | }
55 | },
56 | "Image": {
57 | "card-image": {
58 | "values": {
59 | "objectFit": "fill"
60 | },
61 | "overrides": {}
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/src/controls.tsx:
--------------------------------------------------------------------------------
1 | import { init } from "./react-handoff-chakra";
2 | import defaults from "./controls.json";
3 |
4 | const { createControls, ControlsProvider, components } = init(defaults, {
5 | allowEditing: process.env.NODE_ENV === "development"
6 | });
7 |
8 | export { createControls, ControlsProvider, components };
9 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { CSSReset, ThemeProvider } from "@chakra-ui/core";
2 | import * as React from "react";
3 | import { render } from "react-dom";
4 | import App from "./App";
5 | import { ControlsProvider } from "./controls";
6 | import "./styles.css";
7 |
8 | const rootElement = document.getElementById("root");
9 | render(
10 |
11 |
12 |
13 |
14 |
15 | ,
16 | rootElement
17 | );
18 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/react-handoff-chakra/badge.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge as ChakraBadge,
3 | BadgeProps as ChakraBadgeProps
4 | } from "@chakra-ui/core";
5 | import React, { FC } from "react";
6 | import { CreateControls } from "../react-handoff";
7 | import { select } from "../react-handoff/fields";
8 | import { spaceValues, sizeValues, colorCategoryValues } from "./constants";
9 |
10 | interface BadgeControls {
11 | variantColor: ChakraBadgeProps["variantColor"];
12 | padding: ChakraBadgeProps["padding"];
13 | margin: ChakraBadgeProps["margin"];
14 | fontSize: ChakraBadgeProps["fontSize"];
15 | }
16 |
17 | export const createBadge = (createControls: CreateControls) => {
18 | const useControls = createControls({
19 | key: "Badge",
20 | definitions: {
21 | variantColor: select(colorCategoryValues),
22 | padding: select(spaceValues),
23 | margin: select(spaceValues),
24 | fontSize: select(sizeValues)
25 | }
26 | });
27 |
28 | interface BadgeProps extends ChakraBadgeProps {
29 | controlsKey?: string;
30 | }
31 |
32 | interface ControlledBadgeProps extends ChakraBadgeProps {
33 | controlsKey: string;
34 | }
35 |
36 | const ControlledBadge: FC = ({
37 | controlsKey,
38 | variantColor,
39 | padding,
40 | margin,
41 | fontSize,
42 | ...otherProps
43 | }) => {
44 | const { attach, values } = useControls({
45 | subkey: controlsKey,
46 | passthrough: {
47 | variantColor,
48 | padding,
49 | margin,
50 | fontSize
51 | }
52 | });
53 |
54 | return (
55 |
63 | );
64 | };
65 |
66 | const Badge: FC = props => {
67 | if (props.controlsKey) {
68 | return ;
69 | }
70 | return ;
71 | };
72 |
73 | return Badge;
74 | };
75 |
--------------------------------------------------------------------------------
/src/react-handoff-chakra/box.tsx:
--------------------------------------------------------------------------------
1 | import { Box as ChakraBox, BoxProps as ChakraBoxProps } from "@chakra-ui/core";
2 | import React, { FC } from "react";
3 | import { CreateControls } from "../react-handoff";
4 | import { select } from "../react-handoff/fields";
5 | import { colorValues, spaceValues, sizeValues } from "./constants";
6 |
7 | interface BoxControls {
8 | bg: ChakraBoxProps["bg"];
9 | padding: ChakraBoxProps["padding"];
10 | margin: ChakraBoxProps["margin"];
11 | fontSize: ChakraBoxProps["fontSize"];
12 | color: ChakraBoxProps["color"];
13 | }
14 |
15 | export const createBox = (createControls: CreateControls) => {
16 | const useControls = createControls({
17 | key: "Box",
18 | definitions: {
19 | bg: select(colorValues),
20 | padding: select(spaceValues),
21 | margin: select(spaceValues),
22 | fontSize: select(sizeValues),
23 | color: select(colorValues)
24 | }
25 | });
26 |
27 | interface BoxProps extends ChakraBoxProps {
28 | controlsKey?: string;
29 | }
30 |
31 | interface ControlledBoxProps extends ChakraBoxProps {
32 | controlsKey: string;
33 | }
34 |
35 | const ControlledBox: FC = ({
36 | controlsKey,
37 | bg,
38 | padding,
39 | margin,
40 | fontSize,
41 | color,
42 | ...otherProps
43 | }) => {
44 | const { attach, values } = useControls({
45 | subkey: controlsKey,
46 | passthrough: {
47 | bg,
48 | padding,
49 | margin,
50 | fontSize,
51 | color
52 | }
53 | });
54 |
55 | return (
56 |
65 | );
66 | };
67 |
68 | const Box: FC = props => {
69 | if (props.controlsKey) {
70 | return ;
71 | }
72 | return ;
73 | };
74 |
75 | return Box;
76 | };
77 |
--------------------------------------------------------------------------------
/src/react-handoff-chakra/constants.ts:
--------------------------------------------------------------------------------
1 | export const colorCategoryValues = [
2 | "gray",
3 | "red",
4 | "orange",
5 | "yellow",
6 | "green",
7 | "teal",
8 | "blue",
9 | "cyan",
10 | "purple",
11 | "pink"
12 | ];
13 |
14 | export const colorValues = colorCategoryValues.reduce((colors, baseColor) => {
15 | const variants = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
16 | colors.push(...variants.map(v => baseColor + "." + v));
17 | return colors;
18 | }, [] as string[]);
19 |
20 | export const spaceValues = [
21 | "0",
22 | "2",
23 | "3",
24 | "4",
25 | "5",
26 | "6",
27 | "8",
28 | "10",
29 | "12",
30 | "16",
31 | "20",
32 | "24",
33 | "32",
34 | "40",
35 | "48",
36 | "56",
37 | "64"
38 | ];
39 |
40 | export const sizeValues = [
41 | "xs",
42 | "sm",
43 | "lg",
44 | "xl",
45 | "2xl",
46 | "3xl",
47 | "4xl",
48 | "5xl",
49 | "6xl"
50 | ];
51 |
--------------------------------------------------------------------------------
/src/react-handoff-chakra/image.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Image as ChakraImage,
3 | ImageProps as ChakraImageProps
4 | } from "@chakra-ui/core";
5 | import React, { FC } from "react";
6 | import { select } from "../react-handoff/fields";
7 | import { CreateControls } from "../react-handoff";
8 |
9 | interface ImageControls {
10 | objectFit: ChakraImageProps["objectFit"];
11 | }
12 |
13 | export const createImage = (createControls: CreateControls) => {
14 | const useControls = createControls({
15 | key: "Image",
16 | definitions: {
17 | objectFit: select(["fill", "contain", "cover"])
18 | }
19 | });
20 |
21 | interface ImageProps extends ChakraImageProps {
22 | controlsKey?: string;
23 | }
24 |
25 | interface ControlledImageProps extends ChakraImageProps {
26 | controlsKey: string;
27 | }
28 |
29 | const ControlledImage: FC = ({
30 | controlsKey,
31 | objectFit,
32 | ...otherProps
33 | }) => {
34 | const { attach, values } = useControls({
35 | subkey: controlsKey,
36 | passthrough: {
37 | objectFit
38 | }
39 | });
40 |
41 | return (
42 |
43 | );
44 | };
45 |
46 | const Image: FC = props => {
47 | if (props.controlsKey) {
48 | return ;
49 | }
50 | return ;
51 | };
52 |
53 | return Image;
54 | };
55 |
--------------------------------------------------------------------------------
/src/react-handoff-chakra/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./init";
2 |
--------------------------------------------------------------------------------
/src/react-handoff-chakra/init.ts:
--------------------------------------------------------------------------------
1 | import { init as baseInit } from "../react-handoff";
2 | import { createBadge } from "./badge";
3 | import { createBox } from "./box";
4 | import { createImage } from "./image";
5 |
6 | type BaseInitParams = Parameters;
7 | type BaseInitReturn = ReturnType;
8 |
9 | export const init = (...args: BaseInitParams) => {
10 | const { createControls, ControlsProvider } = baseInit(...args);
11 |
12 | return {
13 | createControls,
14 | ControlsProvider,
15 | components: {
16 | Badge: createBadge(createControls),
17 | Box: createBox(createControls),
18 | Image: createImage(createControls)
19 | }
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/src/react-handoff/dimensions.ts:
--------------------------------------------------------------------------------
1 | import ResizeObserver from "resize-observer-polyfill";
2 |
3 | type Rect = {
4 | top: number;
5 | left: number;
6 | width: number;
7 | height: number;
8 | };
9 |
10 | type DimensionsCallback = (contentRect: Rect) => void;
11 |
12 | export const onDimensions = (el: HTMLElement, callback: DimensionsCallback) => {
13 | const observer = new ResizeObserver(entries => {
14 | if (entries[0]) {
15 | callback(entries[0].target.getBoundingClientRect());
16 | }
17 | });
18 | observer.observe(el);
19 | return () => {
20 | observer.unobserve(el);
21 | observer.disconnect();
22 | };
23 | };
24 |
--------------------------------------------------------------------------------
/src/react-handoff/fields.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from "react";
2 | import { Switch, Select } from "@chakra-ui/core";
3 |
4 | export interface SwitchFieldProps {
5 | value: boolean;
6 | onUpdate: (value: boolean) => void;
7 | }
8 |
9 | // wanted to name this switch but it's a reserved word
10 | export const switchable = () => {
11 | const SwitchField: FC = props => {
12 | return (
13 | props.onUpdate(e.target.checked)}
17 | isChecked={props.value}
18 | />
19 | );
20 | };
21 | return SwitchField;
22 | };
23 |
24 | export interface SelectFieldProps {
25 | value: string;
26 | onUpdate: (value: string) => void;
27 | }
28 |
29 | export const select = (options: string[]) => {
30 | const SelectField: FC = props => {
31 | return (
32 |
45 | );
46 | };
47 | return SelectField;
48 | };
49 |
--------------------------------------------------------------------------------
/src/react-handoff/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./react-handoff";
2 |
--------------------------------------------------------------------------------
/src/react-handoff/react-handoff.tsx:
--------------------------------------------------------------------------------
1 | import React, { ComponentType, FC, useRef, useEffect } from "react";
2 | import {
3 | Heading,
4 | Stack,
5 | Box,
6 | FormLabel,
7 | Flex,
8 | Checkbox,
9 | IconButton
10 | } from "@chakra-ui/core";
11 | import {
12 | atom,
13 | atomFamily,
14 | RecoilRoot,
15 | useRecoilValue,
16 | useRecoilState,
17 | useSetRecoilState,
18 | RecoilState,
19 | useRecoilTransactionObserver_UNSTABLE,
20 | Snapshot
21 | } from "recoil";
22 | import { onDimensions } from "./dimensions";
23 | import { saveAs } from "file-saver";
24 | import { motion } from "framer-motion";
25 |
26 | const MotionBox = motion.custom(Box);
27 |
28 | type AtomFamily = (subkey: string) => RecoilState;
29 |
30 | export type CreateControls = ReturnType["createControls"];
31 |
32 | export interface FieldProps {
33 | value: T;
34 | onUpdate: (val: T) => void;
35 | }
36 |
37 | export type Field = ComponentType>;
38 |
39 | export type ControlDefinitions = {
40 | [K in keyof Controls]: Field;
41 | };
42 |
43 | export interface CreateControlsOptions {
44 | key: string;
45 | definitions: ControlDefinitions;
46 | }
47 |
48 | type Defaults = Record>;
49 |
50 | interface InitOptions {
51 | allowEditing?: boolean;
52 | }
53 |
54 | export const init = (
55 | defaults?: Defaults,
56 | { allowEditing = true }: InitOptions = {}
57 | ) => {
58 | const keyInfo: Record> = {};
59 | const families: Record = {};
60 | const overridesFamilies: Record = {};
61 | const definitionsMap: Record> = {};
62 | const selectedAtom = atom({
63 | key: "selected",
64 | default: {
65 | key: "",
66 | subkey: ""
67 | }
68 | });
69 | const dimensionsAtom = atom({
70 | key: "dimensions",
71 | default: {
72 | top: 0,
73 | left: 0,
74 | height: 0,
75 | width: 0
76 | }
77 | });
78 | const editModeAtom = atom({
79 | key: "edit-mode",
80 | default: false
81 | });
82 | const nullAtom = atom({
83 | key: "null",
84 | default: null
85 | });
86 |
87 | const createControls = (
88 | options: CreateControlsOptions
89 | ) => {
90 | type Overrides = {
91 | [K in keyof Controls]: boolean;
92 | };
93 | const key = options.key;
94 | if (!keyInfo[key]) {
95 | keyInfo[key] = new Set();
96 | }
97 | const definitions = options.definitions;
98 | const family = atomFamily({
99 | key: `${key}-values`,
100 | default: subkey => {
101 | if (
102 | !defaults ||
103 | !defaults[key] ||
104 | !defaults[key][subkey] ||
105 | !defaults[key][subkey].values
106 | ) {
107 | return {};
108 | } else {
109 | return defaults[key][subkey].values;
110 | }
111 | }
112 | });
113 |
114 | const overridesFamily = atomFamily({
115 | key: `${key}-overrides`,
116 | default: subkey => {
117 | if (
118 | !defaults ||
119 | !defaults[key] ||
120 | !defaults[key][subkey] ||
121 | !defaults[key][subkey].overrides
122 | ) {
123 | return {};
124 | } else {
125 | return defaults[key][subkey].overrides;
126 | }
127 | }
128 | });
129 |
130 | families[key] = family;
131 | overridesFamilies[key] = overridesFamily;
132 | definitionsMap[key] = definitions;
133 |
134 | interface UseControlsOptions {
135 | subkey: string;
136 | passthrough?: Partial;
137 | }
138 |
139 | const useControls = (options: UseControlsOptions) => {
140 | keyInfo[key].add(options.subkey);
141 | const ref = useRef();
142 | const [selected, setSelected] = useRecoilState(selectedAtom);
143 | const setDimensions = useSetRecoilState(dimensionsAtom);
144 | let values: any = useRecoilValue(family(options.subkey));
145 | const overrides: any = useRecoilValue(overridesFamily(options.subkey));
146 | const passthrough: any = options.passthrough || {};
147 |
148 | values = { ...values };
149 |
150 | Object.keys(definitions).forEach(key => {
151 | if (!overrides[key]) {
152 | values[key] =
153 | passthrough[key] !== undefined ? passthrough[key] : values[key];
154 | } else {
155 | values[key] = values[key];
156 | }
157 | });
158 |
159 | useEffect(() => {
160 | if (ref.current) {
161 | setDimensions(ref.current.getBoundingClientRect());
162 | }
163 | }, [JSON.stringify(values)]);
164 |
165 | useEffect(() => {
166 | if (
167 | ref.current &&
168 | selected.key === key &&
169 | selected.subkey === options.subkey &&
170 | allowEditing
171 | ) {
172 | let unsubscribeDimensionsListener = onDimensions(
173 | ref.current,
174 | setDimensions
175 | );
176 | const handleWindowResize = () => {
177 | if (ref.current) {
178 | setDimensions(ref.current.getBoundingClientRect());
179 | }
180 | };
181 | window.addEventListener("resize", handleWindowResize);
182 | const unsubscribeWindowResizeListener = () => {
183 | window.removeEventListener("resize", handleWindowResize);
184 | };
185 | return () => {
186 | unsubscribeDimensionsListener();
187 | unsubscribeWindowResizeListener();
188 | };
189 | }
190 | }, [selected.key, selected.subkey, allowEditing]);
191 |
192 | const attach = (el: HTMLElement) => {
193 | if (!allowEditing) {
194 | return null;
195 | }
196 | const handler = (e: MouseEvent) => {
197 | setSelected({ key, subkey: options.subkey });
198 | e.stopPropagation();
199 | e.preventDefault();
200 | };
201 | if (el && el !== ref.current) {
202 | if (ref.current) {
203 | ref.current.removeEventListener("click", handler);
204 | }
205 | el.addEventListener("click", handler);
206 | ref.current = el;
207 | }
208 | };
209 |
210 | return {
211 | attach,
212 | values: values as Controls
213 | };
214 | };
215 |
216 | return useControls;
217 | };
218 |
219 | const Exporter = () => {
220 | const snapshotRef = useRef();
221 | useRecoilTransactionObserver_UNSTABLE(({ snapshot }) => {
222 | snapshotRef.current = snapshot;
223 | });
224 |
225 | const handleClick = () => {
226 | if (snapshotRef.current) {
227 | const obj: any = {};
228 | Object.keys(keyInfo).forEach(key => {
229 | obj[key] = {};
230 | keyInfo[key].forEach(subkey => {
231 | obj[key][subkey] = {
232 | values: (snapshotRef.current as Snapshot).getLoadable(
233 | families[key](subkey)
234 | ).contents,
235 | overrides: (snapshotRef.current as Snapshot).getLoadable(
236 | overridesFamilies[key](subkey)
237 | ).contents
238 | };
239 | });
240 | });
241 | const blob = new Blob([JSON.stringify(obj, null, 2)], {
242 | type: "application/json;charset=utf-8"
243 | });
244 | saveAs(blob, "controls.json");
245 | }
246 | };
247 |
248 | return (
249 |
256 | );
257 | };
258 |
259 | const ToggleEditMode = () => {
260 | const [editMode, setEditMode] = useRecoilState(editModeAtom);
261 |
262 | if (!allowEditing) {
263 | return null;
264 | }
265 |
266 | return (
267 |
268 | {editMode && (
269 | setEditMode(false)}
272 | aria-label="OK"
273 | size="md"
274 | icon={"check"}
275 | />
276 | )}
277 | {!editMode && (
278 | setEditMode(true)}
281 | aria-label="Edit"
282 | size="md"
283 | icon={"edit"}
284 | />
285 | )}
286 |
287 | );
288 | };
289 |
290 | const ControlsPanel: FC = () => {
291 | const { key, subkey } = useRecoilValue(selectedAtom);
292 | const editMode = useRecoilValue(editModeAtom);
293 | const valuesAtom = !key && !subkey ? nullAtom : families[key](subkey);
294 | const overridesAtom =
295 | !key && !subkey ? nullAtom : overridesFamilies[key](subkey);
296 | const [values, setValues] = useRecoilState(valuesAtom);
297 | const [overrides, setOverrides] = useRecoilState(overridesAtom);
298 |
299 | const definitions = definitionsMap[key] || {};
300 |
301 | if (!allowEditing) {
302 | return null;
303 | }
304 |
305 | return (
306 | {
317 | e.stopPropagation();
318 | }}
319 | >
320 |
321 |
322 | Controls
323 |
324 | {!!key && !!subkey ? `${key} > ${subkey}` : ""}
325 |
326 |
327 |
328 |
329 |
330 | {!Object.keys(definitions).length && No selection.}
331 | {Object.keys(definitions).map(fieldName => {
332 | const Field = definitionsMap[key][fieldName];
333 | const value = values[fieldName];
334 | return (
335 |
336 |
337 | {fieldName}
338 | {
342 | setOverrides((prev: any) => ({
343 | ...prev,
344 | [fieldName]: e.target.checked
345 | }));
346 | }}
347 | >
348 | Important
349 |
350 |
351 |
354 | setValues((prev: any) => ({
355 | ...prev,
356 | [fieldName]: val
357 | }))
358 | }
359 | />
360 |
361 | );
362 | })}
363 |
364 |
365 | );
366 | };
367 |
368 | const Clickaway = () => {
369 | const setSelected = useSetRecoilState(selectedAtom);
370 |
371 | useEffect(() => {
372 | if (allowEditing) {
373 | const fn = (e: any) => {
374 | setSelected({ key: "", subkey: "" });
375 | };
376 | window.addEventListener("click", fn);
377 | return () => window.removeEventListener("click", fn);
378 | }
379 | }, []);
380 |
381 | return null;
382 | };
383 |
384 | const SelectedIndicator = () => {
385 | const [dimensions, setDimensions] = useRecoilState(dimensionsAtom);
386 | const { key, subkey } = useRecoilValue(selectedAtom);
387 | const editMode = useRecoilValue(editModeAtom);
388 |
389 | useEffect(() => {
390 | if (!key && !subkey) {
391 | setDimensions({
392 | top: 0,
393 | left: 0,
394 | width: 0,
395 | height: 0
396 | });
397 | }
398 | }, [key, subkey]);
399 |
400 | if (!families[key] || !editMode) {
401 | return null;
402 | }
403 |
404 | return (
405 | {
426 | e.stopPropagation();
427 | }}
428 | pointerEvents="none"
429 | />
430 | );
431 | };
432 |
433 | const ControlsProvider: FC = ({ children }) => {
434 | return (
435 |
436 | {children}
437 |
438 |
439 |
440 |
441 |
442 | );
443 | };
444 |
445 | return {
446 | createControls,
447 | ControlsProvider
448 | };
449 | };
450 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | html, body, #root {
2 | height: 100vh;
3 | }
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "./src/*"
4 | ],
5 | "compilerOptions": {
6 | "lib": [
7 | "dom",
8 | "esnext"
9 | ],
10 | "jsx": "react",
11 | "target": "es6",
12 | "allowJs": true,
13 | "skipLibCheck": true,
14 | "esModuleInterop": true,
15 | "allowSyntheticDefaultImports": true,
16 | "strict": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "resolveJsonModule": true,
21 | "isolatedModules": true,
22 | "noEmit": true
23 | }
24 | }
25 |
--------------------------------------------------------------------------------