├── .gitignore ├── .vscode └── settings.json ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── components │ ├── control-group.tsx │ ├── control-item.tsx │ ├── controls-provider.tsx │ ├── controls.tsx │ └── controls │ │ ├── base-control.tsx │ │ ├── boolean-control.tsx │ │ ├── button-control.tsx │ │ ├── color-control.tsx │ │ ├── file-control.tsx │ │ ├── number-control.tsx │ │ ├── select-control.tsx │ │ ├── string-control.tsx │ │ └── xy-pad-control.tsx ├── contexts │ └── controls-context.ts ├── hooks │ ├── use-control.ts │ └── use-local-storage.ts ├── index.ts ├── types.ts └── utils.ts ├── test └── blah.test.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | dist 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-three-gui 2 | 3 | A graphical user interface for changing variable states in React. 4 | 5 | ## Using this repo 6 | To use this repo, run the following commands inside the repo directory: \ 7 | `yarn install` \ 8 | `yarn build` \ 9 | `yarn pack` \ 10 | This will generate a tarball `.tgz` that you can copy to your project directory. \ 11 | Then you can run: `yarn add ./react-three-gui.tgz` which will install it. 12 | 13 | ## Examples 14 | 15 | https://codesandbox.io/s/react-three-fiber-gui-62pvp 16 | 17 | ![Example](https://media.giphy.com/media/hrvUiMXTTu1aEprRhj/giphy.gif) 18 | 19 | ## Usage 20 | 21 | Basic example 22 | 23 | ```tsx 24 | import { Controls, useControl } from 'react-three-gui'; 25 | 26 | const MyMesh = () => { 27 | const rotationX = useControl('Rotation X', { type: 'number' }); 28 | return ; 29 | } 30 | 31 | 32 | export const App = () => { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | ``` 43 | 44 | Use the spring option to return a react-spring value: 45 | 46 | ```tsx 47 | useControl('My ctrl', { 48 | type: 'number', 49 | spring: true, 50 | }); 51 | 52 | // or pass a react-spring configuration value 53 | 54 | useControl('My ctrl', { 55 | type: 'number', 56 | spring: { mass: 5, tension: 280, friction: 50 }, 57 | }); 58 | ``` 59 | 60 | Also possible to pass in your own state: 61 | 62 | ```tsx 63 | const [value, set] = useState(0); 64 | 65 | useControl('Adjust value', { 66 | type: 'number', 67 | state: [value, set], 68 | }); 69 | ``` 70 | 71 | Also you can pass your own control component: 72 | 73 | ```tsx 74 | const MyControl = ({ value, setValue }) => ( 75 | setValue(e.currentTarget.value)} 78 | value={value} 79 | /> 80 | ); 81 | 82 | useControl('Test', { 83 | type: 'custom', 84 | value: 2, 85 | component: MyControl, 86 | }); 87 | ``` 88 | 89 | ## Use your own Canvas 90 | 91 | ```tsx 92 | import { Canvas } from 'react-three-fiber'; 93 | import { withControls } from 'react-three-gui'; 94 | 95 | // Wrap the with `withControls` 96 | const YourCanvas = withControls(Canvas); 97 | 98 | const Scene = () => ( 99 | 100 | 101 | 102 | ); 103 | 104 | const App = () => { 105 | return ( 106 | 107 | 108 | 109 | 110 | ); 111 | }; 112 | ``` 113 | 114 | ## API 115 | 116 | ```tsx 117 | import { useControl, Controls } from 'react-three-gui'; 118 | 119 | // All the possible options 120 | useControl(name: string, { 121 | // General 122 | type: 'number' | 'xypad' | 'boolean' | 'button' | 'color' | 'select' | 'string' | 'file' | 'custom'; 123 | value: any; // Initial value 124 | spring: boolean | SpringConfig; // Use spring 125 | group: string; // Group name 126 | state: [any, Dispatch>]; // Use your own state 127 | onChange(value: any): void; // onChange callback 128 | 129 | // number | xypad 130 | min: number; // Minimum value (default: 0) 131 | max: number; // Maximum value (default: 1) 132 | distance: number; // The end-to-end slider distance (default: 1) 133 | scrub: boolean; // When slider is released it will reset to the center but keep its value 134 | 135 | // select 136 | items: string[]; 137 | 138 | // button 139 | onClick(): void; 140 | 141 | // file 142 | loader?: THREE.TextureLoader | THREE.FileLoader | etc; 143 | 144 | // custom 145 | component?: React.Component; 146 | }); 147 | 148 | // Controls component 149 | 157 | ``` 158 | 159 | ## Supported controls 160 | 161 | - number 162 | - Returns `number` 163 | - xypad 164 | - Returns `{ x: number, y: number }` object 165 | - boolean 166 | - Returns `boolean` 167 | - button 168 | - Returns `void` 169 | - color 170 | - Returns `string` (as hex: #ffffff) 171 | - select 172 | - Returns `string` 173 | - file 174 | - Returns `new THREE.FileLoader` 175 | - string 176 | - Returns `string` 177 | 178 | ### Future plans 179 | 180 | - [x] Support custom control components 181 | - [x] File upload loader control 182 | - [x] Groups 183 | - [x] Draggable Widget 184 | - [x] Collapsable widget 185 | - [x] Persist state localstorage 186 | - [ ] Multi platform? 187 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import 'react-app-polyfill/ie11'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { animated } from 'react-spring'; 5 | import { a } from '@react-spring/three'; 6 | import { Canvas } from 'react-three-fiber'; 7 | import { Text } from 'drei'; 8 | import * as THREE from 'three'; 9 | import { Controls, useControl, withControls } from '../src'; 10 | import { useEffect } from 'react'; 11 | 12 | const Next = () => { 13 | const rotationX = useControl('Mega', { 14 | group: 'Test', 15 | type: 'number', 16 | spring: true, 17 | }); 18 | return ( 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | const Box = () => { 27 | const ref = React.useRef(); 28 | 29 | const rotationX = useControl('Rotate X', { 30 | group: 'Basic', 31 | type: 'number', 32 | spring: true, 33 | }); 34 | 35 | const rotationY = useControl('Rotate Y', { 36 | type: 'number', 37 | group: 'Basic', 38 | scrub: true, 39 | min: 0, 40 | max: 20, 41 | distance: 10, 42 | spring: { 43 | friction: 2, 44 | mass: 2, 45 | }, 46 | }); 47 | const bool = useControl('Boolean', { 48 | group: 'More', 49 | type: 'boolean', 50 | }); 51 | 52 | const color = useControl('Material color', { 53 | type: 'color', 54 | group: 'Basic', 55 | picker: 'sketch', 56 | }); 57 | 58 | const position = useControl('Position', { 59 | group: 'More', 60 | type: 'xypad', 61 | value: { x: 0, y: 0 }, 62 | distance: 2, 63 | }); 64 | const dropdown = useControl('Pick one', { 65 | group: 'More', 66 | type: 'select', 67 | items: ['foo', 'bar', 'baz'], 68 | }); 69 | const str = useControl('Text', { 70 | group: 'More', 71 | type: 'string', 72 | value: 'example', 73 | }); 74 | const btn = useControl('Clicky', { 75 | group: 'More', 76 | type: 'button', 77 | onClick() { 78 | alert('Hello world'); 79 | }, 80 | }); 81 | 82 | const texture = useControl('Texture', { 83 | group: 'More', 84 | type: 'file', 85 | value: undefined, 86 | loader: new THREE.TextureLoader(), 87 | }); 88 | 89 | useEffect(() => { 90 | if (ref.current) { 91 | (ref.current.material as THREE.Material).needsUpdate = true; 92 | } 93 | }, [texture]); 94 | 95 | return ( 96 | <> 97 | 103 | 104 | 105 | 106 | 111 | {str} 112 | 113 | {dropdown === 'bar' && } 114 | 115 | ); 116 | }; 117 | 118 | const Hello = () => { 119 | const a1 = useControl('1', { type: 'number' }); 120 | const a2 = useControl('2', { type: 'number', max: 10 }); 121 | const a3 = useControl('3', { type: 'number', min: -5, max: 5, value: -2.5 }); 122 | const a4 = useControl('4', { type: 'number', min: 0, max: 200, value: 100 }); 123 | const a5 = useControl('5', { type: 'number', scrub: true }); 124 | const a6 = useControl('6', { type: 'number', scrub: true, distance: 1000 }); 125 | return ( 126 | 127 |

This is a div

128 |
1: {a1}
129 |
2: {a2}
130 |
3: {a3}
131 |
4: {a4}
132 |
5: {a5}
133 |
6: {a6}
134 |
135 | ); 136 | }; 137 | 138 | const Sample2 = () => { 139 | const CtrlCanvas = withControls(Canvas); 140 | return ( 141 | 142 | 143 | 144 | 145 | 146 | ); 147 | }; 148 | 149 | const Sample1 = () => { 150 | return ( 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | ); 159 | }; 160 | 161 | const App = () => { 162 | return ( 163 | 164 |
165 | 166 | 167 |
168 | 169 | 170 |
171 | ); 172 | }; 173 | 174 | ReactDOM.render(, document.getElementById('root')); 175 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "drei": "^1.3.2", 12 | "react": ">= 16.8.0", 13 | "react-app-polyfill": "^1.0.6", 14 | "react-dom": ">= 16.8.0", 15 | "react-spring": "^8.0.27", 16 | "trim-right": "^1.0.1" 17 | }, 18 | "alias": { 19 | "react": "../node_modules/react", 20 | "react-dom": "../node_modules/react-dom/profiling", 21 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling", 22 | "@react-spring/web": "../node_modules/@react-spring/web", 23 | "@react-spring/three": "../node_modules/@react-spring/three" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^16.9.48", 27 | "@types/react-dom": "^16.9.8", 28 | "parcel": "^1.12.4", 29 | "typescript": "^4.0.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-three-gui", 3 | "version": "0.4.1", 4 | "main": "dist/index.js", 5 | "module": "dist/react-three-gui.esm.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "author": "Birkir Gudjonsson ", 11 | "license": "MIT", 12 | "scripts": { 13 | "start": "tsdx watch", 14 | "build": "tsdx build", 15 | "test": "tsdx test --env=jsdom", 16 | "lint": "tsdx lint" 17 | }, 18 | "peerDependencies": { 19 | "@react-three/fiber": "*", 20 | "react": ">=16", 21 | "three": "*" 22 | }, 23 | "prettier": { 24 | "printWidth": 80, 25 | "semi": true, 26 | "singleQuote": true, 27 | "trailingComma": "es5" 28 | }, 29 | "devDependencies": { 30 | "@react-three/fiber": "^4.2.20", 31 | "@types/jest": "^26.0.10", 32 | "@types/react": "^16.9.48", 33 | "@types/react-color": "^3.0.1", 34 | "@types/react-dom": "^16.9.1", 35 | "@types/react-is": "^16.7.1", 36 | "@types/styled-components": "^5.1.2", 37 | "eslint-config-react-app": "^5.2.1", 38 | "husky": "^4.2.5", 39 | "react": "^16.9.0", 40 | "react-dom": "^16.9.0", 41 | "three": "^0.120.0", 42 | "tsdx": "^0.13.3", 43 | "tslib": "^2.0.1", 44 | "typescript": "^4.0.2" 45 | }, 46 | "dependencies": { 47 | "@react-spring/three": "^9.0.0-rc.3", 48 | "@react-spring/web": "^9.0.0-rc.3", 49 | "react-color": "^2.18.1", 50 | "react-is": "^16.13.1", 51 | "react-use-gesture": "^7.0.15", 52 | "styled-components": "^5.1.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/control-group.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { ControlItem } from './control-item'; 4 | 5 | const Heading = styled.h2<{ open: boolean }>` 6 | display: block; 7 | font-family: sans-serif; 8 | font-size: 13px; 9 | font-weight: bold; 10 | padding-left: 16px; 11 | cursor: pointer; 12 | position: relative; 13 | user-select: none; 14 | 15 | &:before, 16 | &:after { 17 | content: ''; 18 | position: absolute; 19 | top: 8px; 20 | right: 16px; 21 | width: 12px; 22 | height: 2px; 23 | background-color: #333; 24 | /* transition: transform 0.25s ease-out; */ 25 | } 26 | &:before { 27 | transform: rotate(${props => (props.open ? 0 : 90)}deg); 28 | } 29 | 30 | &:after { 31 | transform: rotate(${props => (props.open ? 0 : 180)}deg); 32 | } 33 | `; 34 | 35 | const Container = styled.div<{ open: boolean; bg: boolean }>` 36 | background: ${props => (props.bg ? '#f9f9f9' : '#fff')}; 37 | padding: 16px; 38 | display: ${props => (props.open ? 'block' : 'none')}; 39 | margin-bottom: 8px; 40 | `; 41 | 42 | export const ControlGroup = ({ title, controls, options = {} }: any) => { 43 | const [open, setOpen] = useState(!options.defaultClosed ?? true); 44 | const isDefault = title !== 'DEFAULT_GROUP'; 45 | return ( 46 |
47 | {isDefault && ( 48 | setOpen(o => !o)}> 49 | {title} 50 | 51 | )} 52 | 53 | {Array.from(controls).map((control: any) => ( 54 | 55 | ))} 56 | 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/control-item.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import { ControlsContext } from '../contexts/controls-context'; 3 | import { 4 | ControlType, 5 | ControlOptions, 6 | ControlComponentProps, 7 | ControlOptionsCustom, 8 | } from '../types'; 9 | import { NumberControl } from './controls/number-control'; 10 | import { BooleanControl } from './controls/boolean-control'; 11 | import { BaseControl } from './controls/base-control'; 12 | import { ButtonControl } from './controls/button-control'; 13 | import { ColorControl } from './controls/color-control'; 14 | import { SelectControl } from './controls/select-control'; 15 | import { StringControl } from './controls/string-control'; 16 | import { XYPadControl } from './controls/xy-pad-control'; 17 | import { FileControl } from './controls/file-control'; 18 | 19 | const ControlComponents = { 20 | [ControlType.NUMBER]: NumberControl, 21 | [ControlType.BOOLEAN]: BooleanControl, 22 | [ControlType.SELECT]: SelectControl, 23 | [ControlType.COLOR]: ColorControl, 24 | [ControlType.STRING]: StringControl, 25 | [ControlType.BUTTON]: ButtonControl, 26 | [ControlType.FILE]: FileControl, 27 | [ControlType.XYPAD]: XYPadControl, 28 | }; 29 | 30 | const Noop = ({ name, options }: any) => ( 31 | "{options.type}" not found 32 | ); 33 | 34 | export const ControlItem = ({ 35 | name, 36 | id, 37 | value: defaultValue, 38 | options, 39 | }: ControlComponentProps) => { 40 | const context = useContext(ControlsContext); 41 | 42 | const [value, setValue] = useState( 43 | context.values.current && context.values.current.has(id) 44 | ? context.values.current.get(id) 45 | : defaultValue 46 | ); 47 | 48 | useEffect(() => { 49 | context.values.current!.set(id, value); 50 | }, [context.values, id, value]); 51 | 52 | useEffect(() => { 53 | context.gui.current!.set(id, setValue); 54 | }, [context.gui, id]); 55 | 56 | const Component = 57 | (options as ControlOptionsCustom).component ?? 58 | (ControlComponents as any)[options.type ?? ControlType.NUMBER] ?? 59 | Noop; 60 | 61 | return ( 62 | { 67 | context.gui.current?.get(id)?.(newValue); 68 | context.state.current?.get(id)?.(newValue); 69 | }} 70 | options={options} 71 | /> 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/controls-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import { ControlsContext } from '../contexts/controls-context'; 3 | import { Canvas as R3FCanvas } from '@react-three/fiber'; 4 | import { ControlItem } from 'types'; 5 | 6 | export const ControlsProvider = ({ 7 | children, 8 | }: { 9 | children: React.ReactNode; 10 | }) => { 11 | const [controls, setControls] = useState([]); 12 | // Persist values between reloads 13 | const values = useRef(new Map()); 14 | // GUI control state setters 15 | const gui = useRef(new Map()); 16 | // useControl state setters 17 | const state = useRef(new Map()); 18 | 19 | const context = { 20 | values, 21 | gui, 22 | state, 23 | controls, 24 | addControl: (control: ControlItem) => { 25 | control.id = control.id ?? String(Math.random()); 26 | setControls(ctrls => { 27 | return [...ctrls, control]; 28 | }); 29 | return control; 30 | }, 31 | removeControl: (ctrl: ControlItem) => { 32 | setControls(ctrls => ctrls.filter(c => c.id !== ctrl.id)); 33 | }, 34 | }; 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | 43 | export function withControls(CanvasEl: typeof R3FCanvas) { 44 | return ({ children, ...props }: any) => ( 45 | 46 | {value => ( 47 | 48 | 49 | {children} 50 | 51 | 52 | )} 53 | 54 | ); 55 | } 56 | 57 | export const Canvas = withControls(R3FCanvas); 58 | -------------------------------------------------------------------------------- /src/components/controls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { ControlsProvider, Canvas } from './controls-provider'; 3 | import styled from 'styled-components'; 4 | import { animated, useSpring, to } from '@react-spring/web'; 5 | import { useDrag } from 'react-use-gesture'; 6 | import useMeasure from 'react-use-measure'; 7 | import { clamp } from '../utils'; 8 | import { DEFAULT_GROUP } from '../types'; 9 | import { ControlsContext } from '../contexts/controls-context'; 10 | import { useLocalStorage } from '../hooks/use-local-storage'; 11 | import { ControlGroup } from './control-group'; 12 | 13 | const mq = `@media only screen and (max-width: 600px)`; 14 | 15 | interface FloatProps { 16 | 'data-width': number; 17 | 'data-anchor': ControlsAnchor | string; 18 | } 19 | 20 | export enum ControlsAnchor { 21 | TOP_LEFT = 'top_left', 22 | TOP_RIGHT = 'top_right', 23 | BOTTOM_LEFT = 'bottom_left', 24 | BOTTOM_RIGHT = 'bottom_right', 25 | } 26 | 27 | export interface ControlsProps { 28 | /** 29 | * Title to show on the controls 30 | */ 31 | title?: string; 32 | /** 33 | * Collapsed by default 34 | */ 35 | collapsed?: boolean; 36 | /** 37 | * Array of group names as strings 38 | */ 39 | defaultClosedGroups?: string[]; 40 | /** 41 | * Defaults to 300 42 | */ 43 | width?: number; 44 | /** 45 | * Anchor point 46 | */ 47 | anchor?: 48 | | ControlsAnchor 49 | | 'top_left' 50 | | 'bottom_left' 51 | | 'top_right' 52 | | 'bottom_right'; 53 | /** 54 | * Styles 55 | */ 56 | style?: any; 57 | } 58 | 59 | function posProps(positions: ControlsAnchor[]) { 60 | return function posPropsFn(props: any) { 61 | return positions.includes(props['data-anchor']) ? '16px' : 'auto'; 62 | }; 63 | } 64 | 65 | const Float = styled(animated.div)` 66 | display: flex; 67 | flex-direction: column; 68 | position: fixed; 69 | top: ${posProps([ControlsAnchor.TOP_LEFT, ControlsAnchor.TOP_RIGHT])}; 70 | right: ${posProps([ControlsAnchor.BOTTOM_RIGHT, ControlsAnchor.TOP_RIGHT])}; 71 | bottom: ${posProps([ 72 | ControlsAnchor.BOTTOM_RIGHT, 73 | ControlsAnchor.BOTTOM_LEFT, 74 | ])}; 75 | left: ${posProps([ControlsAnchor.TOP_LEFT, ControlsAnchor.BOTTOM_LEFT])}; 76 | width: ${props => props['data-width']}px; 77 | border-radius: 16px; 78 | background-color: #fff; 79 | border-radius: 8px; 80 | box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.12); 81 | 82 | ${mq} { 83 | transform: none !important; 84 | flex-direction: column-reverse; 85 | top: auto; 86 | bottom: 0; 87 | left: 0; 88 | right: 0; 89 | width: auto; 90 | } 91 | `; 92 | 93 | const Header = styled(animated.div)<{ 'data-collapsed': boolean }>` 94 | display: flex; 95 | align-items: center; 96 | position: relative; 97 | padding-left: 16px; 98 | padding-right: 16px; 99 | height: 42px; 100 | font-family: sans-serif; 101 | font-size: 14px; 102 | color: #fff; 103 | cursor: move; 104 | cursor: grab; 105 | user-select: none; 106 | background-color: #000; 107 | border-top-left-radius: 8px; 108 | border-top-right-radius: 8px; 109 | border-bottom-left-radius: ${props => (props['data-collapsed'] ? 0 : 8)}px; 110 | border-bottom-right-radius: ${props => (props['data-collapsed'] ? 0 : 8)}px; 111 | box-shadow: 0 0 14px 0 rgba(0, 0, 0, 0.14); 112 | ${mq} { 113 | border-top-left-radius: 0px; 114 | border-top-right-radius: 0px; 115 | border-bottom-left-radius: 0px; 116 | border-bottom-right-radius: 0px; 117 | } 118 | `; 119 | 120 | const CollapseIcon = styled.div<{ collapsed: boolean }>` 121 | width: 30px; 122 | height: 18px; 123 | display: flex; 124 | align-items: center; 125 | justify-content: center; 126 | margin-left: auto; 127 | cursor: pointer; 128 | &:after { 129 | content: ''; 130 | display: block; 131 | height: 3px; 132 | width: 16px; 133 | background-color: white; 134 | } 135 | ${mq} { 136 | &:before { 137 | content: ''; 138 | display: block; 139 | position: absolute; 140 | top: 0; 141 | right: 0; 142 | bottom: 0; 143 | left: 0; 144 | } 145 | } 146 | `; 147 | 148 | const Items = styled(animated.div)` 149 | padding-bottom: 8px; 150 | overflow-y: auto; 151 | max-height: calc(100vh - 42px); 152 | `; 153 | 154 | const groupByGroup = (items: any): any => { 155 | return Array.from(items).reduce((acc: any, item: any) => { 156 | const groupName = item?.options?.group || DEFAULT_GROUP; 157 | acc[groupName] = acc[groupName] || []; 158 | acc[groupName].push(item); 159 | return acc; 160 | }, {} as { [key: string]: any }); 161 | }; 162 | 163 | interface ControlsFn { 164 | (props: ControlsProps): JSX.Element; 165 | Provider: typeof ControlsProvider; 166 | Canvas: typeof Canvas; 167 | } 168 | 169 | export const Controls: ControlsFn = (props: ControlsProps) => { 170 | const { 171 | title = 'react-three-gui', 172 | defaultClosedGroups = [], 173 | width = 300, 174 | style = {}, 175 | anchor = ControlsAnchor.TOP_RIGHT, 176 | } = props; 177 | const { controls } = useContext(ControlsContext); 178 | const [collapsed, setCollapsed] = useLocalStorage( 179 | 'REACT_THREE_GUI__COLLAPSED', 180 | props.collapsed 181 | ); 182 | const [position, setPosition] = useLocalStorage( 183 | `REACT_THREE_GUI__${anchor}`, 184 | [0, 0] 185 | ); 186 | const [ref, bounds] = useMeasure(); 187 | const [{ pos }, setPos] = useSpring(() => ({ 188 | pos: position, 189 | onRest({ value }) { 190 | setPosition(value); 191 | }, 192 | })); 193 | const left = [ControlsAnchor.TOP_LEFT, ControlsAnchor.BOTTOM_LEFT].includes( 194 | anchor as any 195 | ); 196 | const top = [ControlsAnchor.TOP_RIGHT, ControlsAnchor.TOP_LEFT].includes( 197 | anchor as any 198 | ); 199 | const bind = useDrag( 200 | ({ 201 | movement, 202 | memo = pos 203 | ? (pos as any).getValue 204 | ? (pos as any).getValue() 205 | : (pos as any).get() 206 | : 0, 207 | }) => { 208 | const [x, y] = [movement[0] + memo[0], movement[1] + memo[1]]; 209 | setPos({ 210 | pos: [ 211 | left 212 | ? clamp(x, 1, window.innerWidth - width - 32) 213 | : clamp(x, -window.innerWidth + width + 32, 1), 214 | top 215 | ? clamp(y, 1, window.innerHeight) 216 | : clamp(y, -window.innerHeight + bounds.height + 32, 1), 217 | ], 218 | }); 219 | return memo; 220 | } 221 | ); 222 | 223 | const getGroupOptions = (groupName: string): any => { 224 | return { 225 | defaultClosed: defaultClosedGroups?.includes(groupName) ?? false, 226 | }; 227 | }; 228 | 229 | const groups = groupByGroup(controls); 230 | 231 | return ( 232 | `translate3d(${x}px,${y}px,0)` as any 241 | ), 242 | }} 243 | > 244 |
245 | {title} 246 | setCollapsed((c: boolean) => !c)} 249 | /> 250 |
251 | 268 |
269 | ); 270 | }; 271 | 272 | Controls.Provider = ControlsProvider; 273 | Controls.Canvas = Canvas; 274 | -------------------------------------------------------------------------------- /src/components/controls/base-control.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type SCProps = { 5 | stack?: boolean; 6 | flexLabel?: boolean; 7 | }; 8 | 9 | const Row = styled.div` 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | padding: 8px 0; 14 | `; 15 | 16 | const Label = styled.label` 17 | display: flex; 18 | font-family: sans-serif; 19 | font-size: 14px; 20 | color: rgba(0, 0, 0, 0.4); 21 | width: 56px; 22 | user-select: none; 23 | ${props => (props.flexLabel === true ? 'flex: 1;' : '')} 24 | `; 25 | 26 | const Content = styled.div` 27 | display: flex; 28 | ${props => (props.flexLabel !== true ? 'flex: 1;' : '')} 29 | justify-content: flex-end; 30 | padding: 0 8px; 31 | `; 32 | 33 | const Value = styled.div` 34 | display: flex; 35 | font-family: sans-serif; 36 | white-space: nowrap; 37 | font-size: 14px; 38 | color: rgba(0, 0, 0, 0.75); 39 | justify-content: flex-end; 40 | ${props => (props.stack ? 'flex: 1;' : '')} 41 | ${props => (props.stack ? '' : 'width: 42px;')} 42 | `; 43 | 44 | type BaseControlProps = { 45 | label?: string; 46 | flexLabel?: boolean; 47 | value?: string; 48 | children?: any; 49 | stack?: boolean; 50 | htmlFor?: any; 51 | }; 52 | 53 | export function BaseControl({ 54 | htmlFor, 55 | label, 56 | flexLabel, 57 | value, 58 | stack, 59 | children, 60 | }: BaseControlProps) { 61 | if (stack) { 62 | return ( 63 |
64 | 65 | 66 | 67 | {value} 68 | 69 | 70 | {children} 71 |
72 | ); 73 | } 74 | 75 | return ( 76 | 77 | 80 | {children} 81 | {typeof value !== 'undefined' && {value}} 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/components/controls/boolean-control.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { BaseControl } from "./base-control"; 4 | import { ControlComponentProps, ControlOptionsBoolean } from "../../types"; 5 | 6 | const FakeCheckbox = styled.label` 7 | height: 16px; 8 | width: 16px; 9 | border: 2px solid rgba(0, 0, 0, 0.065); 10 | border-radius: 4px; 11 | position: relative; 12 | margin-top: -1px; 13 | transition: ease-in-out 125ms; 14 | transition-property: background-color, border-color; 15 | `; 16 | 17 | const Checkbox = styled.input` 18 | opacity: 0; 19 | margin-right: -15px; 20 | & + ${FakeCheckbox}:after { 21 | position: absolute; 22 | content: ""; 23 | display: inline-block; 24 | height: 4px; 25 | width: 8px; 26 | border-left: 2px solid; 27 | border-bottom: 2px solid; 28 | left: 3px; 29 | top: 4px; 30 | opacity: 0; 31 | transform: translate(0px, 2px) rotate(-45deg); 32 | transition: ease-in-out 125ms; 33 | transition-property: opacity, transform; 34 | } 35 | &:checked + ${FakeCheckbox}:after { 36 | opacity: 1; 37 | transform: translate(0px, 0px) rotate(-45deg); 38 | } 39 | &:checked + ${FakeCheckbox} { 40 | background: rgba(0, 0, 0, 0.045); 41 | border-color: rgba(0, 0, 0, 0.085); 42 | } 43 | `; 44 | 45 | export function BooleanControl({ 46 | id, 47 | name, 48 | value, 49 | setValue 50 | }: ControlComponentProps) { 51 | const htmlFor = `Control${id}`; 52 | return ( 53 | 54 | setValue(e.currentTarget.checked)} 59 | /> 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/components/controls/button-control.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from 'react'; 2 | import styled from 'styled-components'; 3 | import { ControlComponentProps, ControlOptionsButton } from '../../types'; 4 | 5 | const Button = styled.button` 6 | display: block; 7 | 8 | font-family: sans-serif; 9 | font-size: 14px; 10 | color: rgba(0, 0, 0, 0.4); 11 | 12 | display: block; 13 | position: relative; 14 | 15 | width: 100%; 16 | height: 32px; 17 | 18 | color: #000; 19 | 20 | border: 0; 21 | background-color: rgba(0, 0, 0, 0.045); 22 | border-radius: 4px; 23 | padding: 0 4px; 24 | `; 25 | 26 | export function ButtonControl({ 27 | name, 28 | options, 29 | }: ControlComponentProps) { 30 | return ( 31 |
32 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/controls/color-control.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { 3 | AlphaPicker, 4 | BlockPicker, 5 | ChromePicker, 6 | CirclePicker, 7 | CompactPicker, 8 | GithubPicker, 9 | HuePicker, 10 | MaterialPicker, 11 | SketchPicker, 12 | SliderPicker, 13 | SwatchesPicker, 14 | TwitterPicker, 15 | } from 'react-color'; 16 | import styled from 'styled-components'; 17 | import { BaseControl } from './base-control'; 18 | import { ControlComponentProps, ControlOptionsColor } from '../../types'; 19 | 20 | const ColorPicker = styled.div` 21 | position: relative; 22 | 23 | > div { 24 | box-sizing: border-box !important; 25 | } 26 | `; 27 | 28 | const ColorBox = styled.div` 29 | width: 32px; 30 | height: 16px; 31 | border: 2px solid white; 32 | box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.3); 33 | cursor: pointer; 34 | `; 35 | 36 | const Picker = styled.div<{ hidden: boolean }>` 37 | position: absolute; 38 | top: 24px; 39 | right: 0px; 40 | z-index: 100; 41 | `; 42 | 43 | export function ColorControl({ 44 | name, 45 | value, 46 | setValue, 47 | options, 48 | }: ControlComponentProps) { 49 | const { inline = false, picker = 'chrome' } = options; 50 | const [open, setOpen] = useState(false); 51 | const pickerRef = useRef(); 52 | const handleClick = (e: any) => { 53 | if ( 54 | e.target.id !== 'color-picker' 55 | && pickerRef.current 56 | && !pickerRef.current.contains(e.target) 57 | ) { 58 | setOpen(false); 59 | } 60 | }; 61 | 62 | useEffect(() => { 63 | document.body.addEventListener('click', handleClick); 64 | return () => { 65 | document.body.removeEventListener('click', handleClick); 66 | }; 67 | }, []); 68 | 69 | const pickerProps: any = { 70 | color: value, 71 | onChange: (color: any) => setValue(color.hex), 72 | disableAlpha: options.disableAlpha, 73 | colors: options.colors, 74 | }; 75 | 76 | let PickerElement: any = ChromePicker; 77 | 78 | switch (picker) { 79 | case 'alpha': 80 | PickerElement = AlphaPicker; 81 | break; 82 | case 'block': 83 | PickerElement = BlockPicker; 84 | break; 85 | case 'circle': 86 | PickerElement = CirclePicker; 87 | break; 88 | case 'compact': 89 | PickerElement = CompactPicker; 90 | break; 91 | case 'github': 92 | PickerElement = GithubPicker; 93 | break; 94 | case 'hue': 95 | PickerElement = HuePicker; 96 | break; 97 | case 'material': 98 | PickerElement = MaterialPicker; 99 | break; 100 | case 'sketch': 101 | PickerElement = SketchPicker; 102 | pickerProps.presetColors = options.colors; 103 | break; 104 | case 'slider': 105 | PickerElement = SliderPicker; 106 | break; 107 | case 'swatches': 108 | PickerElement = SwatchesPicker; 109 | break; 110 | case 'twitter': 111 | PickerElement = TwitterPicker; 112 | break; 113 | } 114 | 115 | return ( 116 | 117 | 118 | {inline ? ( 119 | 124 | ) : ( 125 | <> 126 | setOpen(lastValue => !lastValue)} 130 | /> 131 | 134 | 135 | )} 136 | 137 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/components/controls/file-control.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { BaseControl } from './base-control'; 4 | import { ControlComponentProps, ControlOptionsFile } from '../../types'; 5 | import * as THREE from 'three'; 6 | 7 | const FileInput = styled.input` 8 | width: 100%; 9 | `; 10 | 11 | export const FileControl = ({ 12 | name, 13 | setValue, 14 | options, 15 | }: ControlComponentProps) => { 16 | return ( 17 | 18 | { 21 | const loader = options.loader ?? new THREE.FileLoader(); 22 | if ((loader as any).setCrossOrigin) { 23 | (loader as THREE.TextureLoader).setCrossOrigin(''); 24 | } 25 | const file = e.currentTarget.files && e.currentTarget.files[0]; 26 | const texture = loader.load(URL.createObjectURL(file)); 27 | setValue(texture); 28 | }} 29 | /> 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/controls/number-control.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 3 | import { clamp, map } from '../../utils'; 4 | import { BaseControl } from './base-control'; 5 | import { ControlComponentProps, ControlOptionsNumber } from '../../types'; 6 | 7 | const InputRange = styled.input` 8 | -webkit-appearance: none; 9 | width: 100%; 10 | background: transparent; 11 | display: inline-block; 12 | 13 | &:focus { 14 | outline: none; 15 | } 16 | 17 | &::-webkit-slider-runnable-track { 18 | width: 100%; 19 | height: 12px; 20 | cursor: pointer; 21 | background: rgba(0, 0, 0, 0.045); 22 | border-radius: 10px; 23 | } 24 | 25 | &::-webkit-slider-thumb { 26 | border: none; 27 | height: 20px; 28 | width: 20px; 29 | border-radius: 50%; 30 | background: #ffffff; 31 | cursor: pointer; 32 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.22); 33 | -webkit-appearance: none; 34 | margin-top: -4px; 35 | } 36 | 37 | &:focus::-webkit-slider-runnable-track { 38 | outline: none; 39 | } 40 | `; 41 | 42 | const PRECISION = 300; 43 | const CENTER = PRECISION / 2; 44 | 45 | export const NumberControl = ({ 46 | name, 47 | value, 48 | setValue, 49 | options, 50 | }: ControlComponentProps) => { 51 | const ref = useRef(null); 52 | const stage = useRef(null); 53 | const { 54 | min = options.scrub ? -Infinity : 0, 55 | max = options.scrub ? Infinity : Math.PI, 56 | } = options; 57 | 58 | const distance = options.distance ?? options.scrub ? 2 : max - min; 59 | 60 | const [val, setVal] = useState( 61 | options.scrub ? CENTER : map(value, min, max, 0, PRECISION) 62 | ); 63 | 64 | const handleChange = useCallback(() => { 65 | if (options.scrub) { 66 | setVal(CENTER); 67 | stage.current = null; 68 | } 69 | }, [options.scrub]); 70 | 71 | useEffect(() => { 72 | const el = ref.current; 73 | if (el) { 74 | el.addEventListener('change', handleChange); 75 | } 76 | return () => { 77 | if (el) { 78 | el.removeEventListener('change', handleChange); 79 | } 80 | }; 81 | }, [handleChange, ref]); 82 | 83 | useEffect(() => { 84 | setVal(options.scrub ? CENTER : map(value, min, max, 0, PRECISION)); 85 | }, [value]) 86 | 87 | return ( 88 | 89 | { 96 | const num = Number(e.target.value); 97 | setVal(num); 98 | if (stage.current === null) { 99 | stage.current = value; 100 | } 101 | const cvalue = 102 | (options.scrub ? (stage as any).current : 0) + 103 | map( 104 | num - (options.scrub ? CENTER : 0), 105 | 0, 106 | PRECISION, 107 | options.scrub ? 0 : min, 108 | options.scrub ? distance * 2 : max 109 | ); 110 | setValue(clamp(cvalue, min, max)); 111 | }} 112 | /> 113 | 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /src/components/controls/select-control.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { BaseControl } from './base-control'; 4 | import { ControlComponentProps, ControlOptionsSelect } from '../../types'; 5 | 6 | const Select = styled.select` 7 | display: block; 8 | 9 | font-family: sans-serif; 10 | font-size: 14px; 11 | color: rgba(0, 0, 0, 0.4); 12 | 13 | display: block; 14 | position: relative; 15 | 16 | width: 100%; 17 | height: 32px; 18 | 19 | color: #000; 20 | 21 | margin-left: 8px; 22 | 23 | border: 0; 24 | background-color: rgba(0, 0, 0, 0.025); 25 | border-radius: 4px; 26 | padding: 0 4px; 27 | `; 28 | 29 | export function SelectControl({ 30 | name, 31 | setValue, 32 | value, 33 | options, 34 | }: ControlComponentProps) { 35 | const { items = [] } = options; 36 | return ( 37 | 38 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/controls/string-control.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { BaseControl } from './base-control'; 4 | import { ControlComponentProps, ControlOptionsString } from '../../types'; 5 | 6 | const Input = styled.input` 7 | display: block; 8 | 9 | font-family: sans-serif; 10 | font-size: 14px; 11 | color: rgba(0, 0, 0, 0.4); 12 | 13 | display: block; 14 | position: relative; 15 | 16 | width: 100%; 17 | height: 32px; 18 | 19 | color: #000; 20 | 21 | margin-left: 8px; 22 | 23 | border: 0; 24 | background-color: rgba(0, 0, 0, 0.025); 25 | border-radius: 4px; 26 | padding: 0 4px; 27 | `; 28 | 29 | export const StringControl = React.memo( 30 | ({ name, setValue, value }: ControlComponentProps) => { 31 | return ( 32 | 33 | setValue(e.target.value)} /> 34 | 35 | ); 36 | } 37 | ); 38 | -------------------------------------------------------------------------------- /src/components/controls/xy-pad-control.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { animated, useSpring, to } from '@react-spring/web'; 3 | import { useDrag } from 'react-use-gesture'; 4 | import useMeasure from 'react-use-measure'; 5 | import { clamp, map } from '../../utils'; 6 | import { BaseControl } from './base-control'; 7 | import { ControlComponentProps, ControlOptionsXYPad } from '../../types'; 8 | 9 | // When to just show 0 instead of 0.000000012333 10 | const THRESHOLD = 0.00001; 11 | 12 | export const XYPadControl = ({ 13 | name, 14 | value, 15 | setValue, 16 | options, 17 | }: ControlComponentProps) => { 18 | const stage = useRef(null); 19 | const { distance = 1, scrub = false } = options; 20 | const [ref, { width, height }] = useMeasure(); 21 | 22 | const [cursor, setCursor] = useSpring( 23 | () => ({ 24 | from: { 25 | x: value.x, 26 | y: value.y, 27 | }, 28 | 29 | onChange(value: any, b: any) { 30 | const clampMx = b.key === 'x' ? width : height; 31 | const v = 32 | clamp(map(value, 0, clampMx / 2, 0, distance), -distance, distance) || 33 | 0; 34 | if (!scrub) { 35 | setValue((prev: any) => ({ 36 | ...prev, 37 | [b.key]: v < THRESHOLD && v > -THRESHOLD ? 0 : v, 38 | })); 39 | } 40 | }, 41 | }), 42 | [width, height] 43 | ) as any; 44 | 45 | const bind = useDrag(({ down, movement }) => { 46 | if (down && !stage.current) { 47 | stage.current = value; 48 | } else if (!down) { 49 | stage.current = null; 50 | } 51 | 52 | setCursor({ x: down ? movement[0] : 0, y: down ? movement[1] : 0 }); 53 | 54 | if (scrub) { 55 | if (down) { 56 | setValue(() => ({ 57 | x: 58 | (stage as any).current.x + 59 | map(movement[0], 0, width / 2, 0, distance), 60 | y: 61 | (stage as any).current.y + 62 | map(movement[1], 0, height / 2, 0, distance), 63 | })); 64 | } else { 65 | stage.current = value; 66 | } 67 | } 68 | }); 69 | 70 | const x = cursor.x.to((n: number) => clamp(n + width / 2, 0, width)); 71 | const y = cursor.y.to((n: number) => clamp(n + height / 2, 0, height)); 72 | 73 | return ( 74 | 79 | 91 | 92 | 93 | 94 | `translate(${x}px, ${y}px)`), 97 | }} 98 | > 99 | 100 | 101 | 102 | 103 | 104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/contexts/controls-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, RefObject, SetStateAction } from 'react'; 2 | import { ControlItem } from 'types'; 3 | 4 | interface IControlsContext { 5 | values: RefObject>; 6 | gui: RefObject>>; 7 | state: RefObject>>; 8 | controls: ControlItem[]; 9 | addControl(control: ControlItem): ControlItem; 10 | removeControl(control: ControlItem): any; 11 | } 12 | 13 | export const ControlsContext = createContext({ 14 | values: { current: new Map() }, 15 | gui: { current: new Map() }, 16 | state: { current: new Map() }, 17 | controls: [], 18 | addControl(control: ControlItem) { 19 | return control; 20 | }, 21 | removeControl() {}, 22 | }); 23 | -------------------------------------------------------------------------------- /src/hooks/use-control.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from 'react'; 2 | import { useSpring } from '@react-spring/three'; 3 | import { ControlsContext } from '../contexts/controls-context'; 4 | import { ControlOptions, ControlItem } from '../types'; 5 | import { defaultOptions, defaultValue } from '../utils'; 6 | 7 | export const useControl = ( 8 | name: string, 9 | options: ControlOptions = defaultOptions 10 | ) => { 11 | const context = useContext(ControlsContext); 12 | const [id, setId] = useState(null); 13 | let [value, setValue] = useState(defaultValue(options)); 14 | 15 | const [spring, setSpring] = useSpring(() => ({ 16 | value, 17 | config: typeof options.spring === 'object' ? options.spring : undefined, 18 | })); 19 | const setSpringValue = (springValue: any) => setSpring({ value: springValue }); 20 | 21 | if (options.state) { 22 | value = options.state[0]; 23 | setValue = options.state[1]; 24 | } 25 | 26 | useEffect(() => { 27 | if (context.state.current && id) { 28 | if (options.spring) { 29 | context.state.current.set(id, setSpringValue); 30 | } else { 31 | context.state.current.set(id, setValue); 32 | } 33 | } 34 | }, [context.state, id]); 35 | 36 | useEffect(() => { 37 | const ctrl = context.addControl({ 38 | id: options.id, 39 | name, 40 | value, 41 | options, 42 | } as ControlItem); 43 | setId(ctrl.id); 44 | return () => { 45 | context.removeControl(ctrl); 46 | }; 47 | }, []); 48 | 49 | useEffect(() => { 50 | options.spring && void setSpring({ value }); 51 | options.onChange && void options.onChange(value); 52 | 53 | // prevent stale gui 54 | id && context.gui.current?.get(id)?.(value) 55 | 56 | }, [options, setSpring, value]); 57 | 58 | if (options.spring) { 59 | return spring.value; 60 | } 61 | 62 | return value; 63 | }; 64 | -------------------------------------------------------------------------------- /src/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export function useLocalStorage(key: string, initialValue: any) { 4 | const [storedValue, setStoredValue] = useState(() => { 5 | try { 6 | const item = window.localStorage.getItem(key); 7 | return item ? JSON.parse(item) : initialValue; 8 | } catch (error) { 9 | console.log(error); 10 | return initialValue; 11 | } 12 | }); 13 | 14 | const setValue = (value: any) => { 15 | try { 16 | const valueToStore = 17 | value instanceof Function ? value(storedValue) : value; 18 | setStoredValue(valueToStore); 19 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 20 | } catch (error) { 21 | console.log(error); 22 | } 23 | }; 24 | 25 | return [storedValue, setValue]; 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Controls } from './components/controls'; 2 | export { withControls } from './components/controls-provider'; 3 | export { useControl } from './hooks/use-control'; 4 | export { BaseControl } from './components/controls/base-control'; 5 | export { ControlsContext } from './contexts/controls-context'; 6 | export * from './types'; 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, MouseEvent } from 'react'; 2 | import { SpringConfig } from '@react-spring/three'; 3 | 4 | export const DEFAULT_GROUP = 'DEFAULT_GROUP'; 5 | 6 | export type ControlItem = { 7 | id: string; 8 | name: string; 9 | value: any; 10 | options: ControlOptions; 11 | }; 12 | 13 | export enum ControlType { 14 | NUMBER = 'number', 15 | STRING = 'string', 16 | BUTTON = 'button', 17 | BOOLEAN = 'boolean', 18 | SELECT = 'select', 19 | COLOR = 'color', 20 | XYPAD = 'xypad', 21 | FILE = 'file', 22 | CUSTOM = 'custom', 23 | } 24 | 25 | export type ControlComponentProps = ControlItem & { 26 | setValue(value: any): void; 27 | options: T; 28 | }; 29 | 30 | export type ControlOptionsBase = { 31 | /** Unique id for control */ 32 | id?: string; 33 | /* The control type */ 34 | type: ControlType | string; 35 | /** Default value */ 36 | value?: any; 37 | /* Return useSpring instead of useState */ 38 | spring?: boolean | SpringConfig; 39 | /* Group this control */ 40 | group?: string; 41 | /* Use your own state */ 42 | state?: [any, Dispatch>]; 43 | /* onChange callback */ 44 | onChange?(value: any): void; 45 | }; 46 | 47 | export type ControlOptionsNumber = { 48 | type: ControlType.NUMBER | 'number'; 49 | /* Minimum value */ 50 | min?: number; 51 | /* Maximum value */ 52 | max?: number; 53 | /* Initial value */ 54 | value?: number; 55 | /* Slider distance */ 56 | distance?: number; 57 | /* Scrub value in both directions */ 58 | scrub?: boolean; 59 | }; 60 | 61 | export type ControlOptionsString = { 62 | type: ControlType.STRING | 'string'; 63 | /* Initial value */ 64 | value?: string; 65 | }; 66 | 67 | export type ControlOptionsFile = { 68 | type: ControlType.FILE | 'file'; 69 | /* Initial value */ 70 | value?: string; 71 | /** Loader */ 72 | loader?: { 73 | load(url: string): any; 74 | }; 75 | }; 76 | 77 | export type ControlOptionsButton = { 78 | type: ControlType.BUTTON | 'button'; 79 | /* Fired on button click */ 80 | onClick?(e: MouseEvent): any; 81 | }; 82 | 83 | export type ControlOptionsBoolean = { 84 | type: ControlType.BOOLEAN | 'boolean'; 85 | value?: boolean; 86 | }; 87 | 88 | export type ControlOptionsSelect = { 89 | type: ControlType.SELECT | 'select'; 90 | /* List of items to select from */ 91 | items: string[]; 92 | /* Initial value */ 93 | value?: string; 94 | }; 95 | 96 | export type ControlOptionsColor = { 97 | type: ControlType.COLOR | 'color'; 98 | /* Initial value as HEX code */ 99 | value?: string; 100 | /* Show picker as inline */ 101 | inline?: boolean; 102 | /* What kind of picker. Default Chrome */ 103 | picker?: 104 | | 'chrome' 105 | | 'sketch' 106 | | 'hue' 107 | | 'alpha' 108 | | 'block' 109 | | 'github' 110 | | 'twitter' 111 | | 'circle' 112 | | 'material' 113 | | 'compact' 114 | | 'slider' 115 | | 'swatches'; 116 | /* Disable alpha */ 117 | disableAlpha?: boolean; 118 | /* Custom set of colors */ 119 | colors?: string[]; 120 | }; 121 | 122 | export type ControlOptionsXYPad = { 123 | type: ControlType.XYPAD | 'xypad'; 124 | /* Initial value as { x, y } object */ 125 | value?: { x: number; y: number }; 126 | /* Pad drag distance */ 127 | distance?: number; 128 | /* Scrub value in both directions */ 129 | scrub?: boolean; 130 | }; 131 | 132 | export type ControlOptionsCustom = { 133 | type: ControlType.CUSTOM | 'custom'; 134 | /* Custom React component */ 135 | component?: any; 136 | }; 137 | 138 | export type ControlOptions = ControlOptionsBase & 139 | ( 140 | | ControlOptionsCustom 141 | | ControlOptionsNumber 142 | | ControlOptionsBoolean 143 | | ControlOptionsString 144 | | ControlOptionsButton 145 | | ControlOptionsColor 146 | | ControlOptionsSelect 147 | | ControlOptionsFile 148 | | ControlOptionsXYPad 149 | ); 150 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { ControlOptions, ControlType } from './types'; 2 | import * as THREE from 'three'; 3 | 4 | export const defaultOptions: ControlOptions = { 5 | type: ControlType.NUMBER, 6 | value: 0, 7 | }; 8 | 9 | export const defaultValue = (options: ControlOptions) => { 10 | if (options.hasOwnProperty('value')) { 11 | return options.value; 12 | } 13 | 14 | switch (options.type) { 15 | case ControlType.NUMBER: 16 | return 0; 17 | case ControlType.COLOR: 18 | return '#ff0000'; 19 | case ControlType.STRING: 20 | return ''; 21 | case ControlType.SELECT: 22 | return (options.items || [''])[0]; 23 | case ControlType.BOOLEAN: 24 | return false; 25 | case ControlType.FILE: 26 | return new THREE.FileLoader(); 27 | case ControlType.XYPAD: 28 | return { x: 0, y: 0 }; 29 | } 30 | 31 | return undefined; 32 | }; 33 | 34 | export const clamp = (num: number, clamp: number, higher?: number) => 35 | higher !== undefined 36 | ? Math.min(Math.max(num, clamp), higher) 37 | : Math.min(num, clamp); 38 | 39 | export const map = ( 40 | value: number, 41 | x1: number, 42 | y1: number, 43 | x2: number, 44 | y2: number 45 | ) => ((value - x1) * (y2 - x2)) / (y1 - x1) + x2; 46 | -------------------------------------------------------------------------------- /test/blah.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Thing } from '../src'; 4 | 5 | describe('it', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | ReactDOM.unmountComponentAtNode(div); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "allowSyntheticDefaultImports": true, 17 | "noImplicitThis": true, 18 | "alwaysStrict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "moduleResolution": "node", 24 | "baseUrl": "./", 25 | "paths": { 26 | "*": ["src/*", "node_modules/*"] 27 | }, 28 | "jsx": "react", 29 | "esModuleInterop": true 30 | } 31 | } 32 | --------------------------------------------------------------------------------