",
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 | `calc(100vh - ${y + 92}px)` as any)
256 | : undefined,
257 | }}
258 | >
259 | {Object.entries(groups).map(([groupName, items]: any) => (
260 |
266 | ))}
267 |
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 |
132 |
133 |
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