├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── lint.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── .size-snapshot.json ├── LICENSE ├── README.md ├── _screenshot.png ├── example ├── .npmignore ├── index.html ├── package.json ├── src │ ├── App.tsx │ ├── Badge.tsx │ ├── index.tsx │ └── styles.css ├── static │ ├── cube │ │ ├── nx.png │ │ ├── ny.png │ │ ├── nz.png │ │ ├── px.png │ │ ├── py.png │ │ └── pz.png │ └── suzanne.glb ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── data.ts ├── helpers.ts ├── index.ts ├── types.ts ├── useTweaks.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 15 | - run: yarn install --frozen-lockfile --check-files 16 | - uses: actions/cache@v1 17 | id: cache-build 18 | with: 19 | path: "." 20 | key: ${{ github.sha }} 21 | 22 | lint: 23 | runs-on: ubuntu-latest 24 | needs: build 25 | steps: 26 | - uses: actions/cache@v1 27 | id: restore-build 28 | with: 29 | path: "." 30 | key: ${{ github.sha }} 31 | - run: yarn eslint 32 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Release 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up Git repository 16 | uses: actions/checkout@v2 17 | - name: Setup Node 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 12 21 | registry-url: https://registry.npmjs.org/ 22 | - name: Install deps and build 23 | run: yarn 24 | - name: Create a new release 25 | id: create_release 26 | uses: actions/create-release@v1.1.1 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | with: 30 | tag_name: ${{ github.ref }} 31 | release_name: ${{ github.ref }} 32 | body: | 33 | # Changes 34 | - TODO 35 | draft: true 36 | prerelease: false 37 | - name: Publish to NPM 38 | run: npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | build/ 13 | dist/ 14 | .cache/ 15 | .parcel-cache/ 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | example/ 4 | dist/ 5 | build/ 6 | Thumbs.db 7 | ehthumbs.db 8 | Desktop.ini 9 | $RECYCLE.BIN/ 10 | .DS_Store 11 | .vscode 12 | .docz/ 13 | package-lock.json 14 | coverage/ 15 | .idea 16 | yarn-error.log 17 | .size-snapshot.json 18 | pnpm-debug.log 19 | .parcel-cache 20 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "index.js": { 3 | "bundled": 5666, 4 | "minified": 2810, 5 | "gzipped": 1163, 6 | "treeshaked": { 7 | "rollup": { 8 | "code": 1286, 9 | "import_statements": 107 10 | }, 11 | "webpack": { 12 | "code": 2424 13 | } 14 | } 15 | }, 16 | "index.cjs.js": { 17 | "bundled": 7679, 18 | "minified": 4114, 19 | "gzipped": 1447 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Poimandres 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![A screenshot of the library in use](https://i.imgur.com/A7yL1zE.jpg) 2 | [![npm](https://img.shields.io/npm/v/use-tweaks?style=flat-square)](https://www.npmjs.com/package/use-tweaks) ![npm](https://img.shields.io/npm/dt/use-tweaks.svg?style=flat-square) [![Discord Shield](https://discordapp.com/api/guilds/740090768164651008/widget.png?style=shield)](https://discord.gg/ZZjjNvJ) 3 | 4 | Use [Tweakpane](http://cocopon.github.io/tweakpane/) in React apps 5 | 6 | ## Try it here on [Codesandbox](https://codesandbox.io/s/use-tweaks-example-ekqv2) 7 | 8 | ``` 9 | npm install tweakpane use-tweaks 10 | ``` 11 | 12 | ## Basic example 13 | 14 | ```jsx 15 | import { useTweaks } from "use-tweaks" 16 | 17 | function MyComponent() { 18 | const { speed, factor } = useTweaks({ 19 | speed: 1, 20 | factor: { value: 1, min: 10, max: 100 }, 21 | }); 22 | 23 | return ( 24 |
25 | {speed} * {factor} 26 |
27 | ); 28 | } 29 | ``` 30 | 31 | ## Misc 32 | 33 | #### Folders 34 | 35 | You can add a top-level folder by passing the name as first argument of the hook: 36 | 37 | ```jsx 38 | import { useTweaks } from "use-tweaks" 39 | 40 | const { speed, factor } = useTweaks("My title!", { speed: 1, factor: 1 }) 41 | ``` 42 | 43 | You can also nest folders by using the `makeFolder` helper: 44 | 45 | ```jsx 46 | import { useTweaks, makeFolder } from "use-tweaks" 47 | 48 | const { speed, factor } = useTweaks("My Title!", { 49 | speed: 1, 50 | ...makeFolder( 51 | "Advanced", 52 | { 53 | factor: 1, 54 | }, 55 | false 56 | ), // pass false to make the folder collapsed by default 57 | }) 58 | ``` 59 | 60 | #### Buttons 61 | 62 | Use the `makeButton` helper to create and add a button 63 | 64 | ```jsx 65 | import { useTweaks, makeButton } from "use-tweaks" 66 | 67 | const { speed, factor } = useTweaks({ 68 | speed: 1, 69 | factor: { value: 1, min: 10, max: 100 }, 70 | ...makeButton("Log!", () => console.log("Hello World!")) 71 | }) 72 | ``` 73 | 74 | #### Separator 75 | 76 | Use the `makeSeparator` helper to add a separator 77 | 78 | ```jsx 79 | import { useTweaks, makeSeparator } from "use-tweaks" 80 | 81 | const { speed, factor } = useTweaks({ 82 | speed: 1, 83 | ...makeSeparator(), 84 | factor: { value: 1, min: 10, max: 100 }, 85 | }) 86 | ``` 87 | 88 | ## License 89 | 90 | This project is open source and available under the [MIT License](LICENSE) 91 | -------------------------------------------------------------------------------- /_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/_screenshot.png -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .parcel-cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "./src/index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "copy": "mkdir -p dist && cp -r static/* dist/", 8 | "start": "yarn copy && parcel serve index.html", 9 | "clean": "rm -rf .parcel-cache dist", 10 | "build": "parcel build index.html" 11 | }, 12 | "dependencies": { 13 | "@react-three/drei": "^2.2.3", 14 | "@react-three/postprocessing": "^1.4.1", 15 | "postprocessing": "^6.17.4", 16 | "react": "^17.0.0", 17 | "react-app-polyfill": "^1.0.0", 18 | "react-dom": "^17.0.0", 19 | "react-three-fiber": "^5.1.4", 20 | "three": "^0.122.0", 21 | "tweakpane": "^3.0.5", 22 | "use-tweaks": "^0.1.0" 23 | }, 24 | "alias": { 25 | "use-tweaks": "../src", 26 | "react": "../node_modules/react", 27 | "react-dom": "../node_modules/react-dom/profiling", 28 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 29 | }, 30 | "devDependencies": { 31 | "@types/react": "^16.9.11", 32 | "@types/react-dom": "^16.8.4", 33 | "parcel": "^2.0.0-rc.0", 34 | "typescript": "^4.0.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import React, { Suspense, useRef } from 'react' 3 | import { Canvas, useFrame } from 'react-three-fiber' 4 | import { OrbitControls, ContactShadows, useGLTF, useCubeTexture, Octahedron } from '@react-three/drei' 5 | import { useTweaks, makeFolder, makeSeparator, makeButton, makeMonitor } from 'use-tweaks' 6 | 7 | import Badge from './Badge' 8 | 9 | function Suzanne(props) { 10 | const { envMap } = props 11 | const { nodes } = useGLTF('./suzanne.glb') as any 12 | 13 | const { color, position, scale } = useTweaks('Suzanne', { 14 | color: 'rgb(255, 0, 91)', 15 | ...makeSeparator(), 16 | ...makeFolder('Position', { 17 | position: { value: { x: 0, y: 0 }, min: { x: -1, y: -1 }, max: { x: 1, y: 1 } }, 18 | }), 19 | ...makeFolder('Scale', { 20 | scale: { value: 1, max: 3 }, 21 | ...makeButton('Log Console', () => console.log('something in the console ' + Date.now())), 22 | }), 23 | }) 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | function Octa({ envMap }) { 34 | const mesh = useRef() 35 | const sin = useRef(0) 36 | 37 | const { move, speed } = useTweaks('Octa', { 38 | speed: { value: 1, min: 1, max: 4 }, 39 | ...makeMonitor('myMonitor', sin, { 40 | view: 'graph', 41 | min: -1, 42 | max: +2, 43 | }), 44 | ...makeMonitor('fnMonitor', Math.random, { 45 | view: 'graph', 46 | min: -0.5, 47 | max: 1.5, 48 | interval: 100, 49 | }), 50 | ...makeMonitor("TestMonitor", sin, { 51 | multiline: true, 52 | count: 10, 53 | interval: 16 54 | }), 55 | move: true, 56 | }) 57 | 58 | useFrame(({ clock }) => { 59 | if (move) { 60 | const s = Math.sin(clock.getElapsedTime() * speed) 61 | const c = Math.cos(clock.getElapsedTime() * 2 * speed) 62 | sin.current = s * s * c + 0.9 63 | if (mesh.current) { 64 | mesh.current.position.y = sin.current 65 | } 66 | } 67 | }) 68 | 69 | return ( 70 | 71 | 72 | 73 | ) 74 | } 75 | 76 | function Scene() { 77 | const envMap = useCubeTexture(['px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png'], { path: '/cube/' }) 78 | 79 | const { model } = useTweaks({ 80 | model: { value: 'Octahedron', options: ['suzanne', 'Octahedron'] }, 81 | }) 82 | 83 | return ( 84 | 85 | {model === 'Octahedron' ? : } 86 | 87 | ) 88 | } 89 | 90 | export default function App() { 91 | const ref = React.useRef(null) 92 | const { bgColor } = useTweaks({ bgColor: { value: '#f2f2f2' } }, { container: ref, title: 'Parameters' }) 93 | 94 | return ( 95 | <> 96 | 97 | 98 | 99 | 100 | 101 | 102 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
120 | 121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /example/src/Badge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Badge() { 4 | return ( 5 | 6 | 7 | 11 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import App from './App' 5 | import './styles.css' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /example/src/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | .badge { 15 | position: fixed; 16 | bottom: 1rem; 17 | left: 1rem; 18 | z-index: 10; 19 | } 20 | 21 | .tweak-container { 22 | position: fixed; 23 | top: 1rem; 24 | left: 1rem; 25 | width: 256px; 26 | z-index: 10; 27 | } 28 | -------------------------------------------------------------------------------- /example/static/cube/nx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/cube/nx.png -------------------------------------------------------------------------------- /example/static/cube/ny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/cube/ny.png -------------------------------------------------------------------------------- /example/static/cube/nz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/cube/nz.png -------------------------------------------------------------------------------- /example/static/cube/px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/cube/px.png -------------------------------------------------------------------------------- /example/static/cube/py.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/cube/py.png -------------------------------------------------------------------------------- /example/static/cube/pz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/cube/pz.png -------------------------------------------------------------------------------- /example/static/suzanne.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweakpane/use-tweaks/631c211de8e4a51c839e761bf63e75bd05ed40a3/example/static/suzanne.glb -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "target": "es5", 6 | "module": "commonjs", 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noImplicitAny": false, 10 | "noUnusedLocals": false, 11 | "noUnusedParameters": false, 12 | "removeComments": true, 13 | "strictNullChecks": true, 14 | "preserveConstEnums": true, 15 | "sourceMap": true, 16 | "lib": ["es2015", "es2016", "dom"], 17 | "types": ["node"], 18 | "paths": { 19 | "use-tweaks": ["../src"] 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-tweaks", 3 | "version": "0.3.2-alpha.0", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "module": "dist/use-tweaks.esm.js", 7 | "files": [ 8 | "dist", 9 | "src" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/gsimone/use-tweaks.git" 14 | }, 15 | "peerDependencies": { 16 | "react": ">= 16.8.0", 17 | "tweakpane": "^3.0.5" 18 | }, 19 | "devDependencies": { 20 | "@size-limit/preset-small-lib": "^4.7.0", 21 | "@tweakpane/core": "^1.0.6", 22 | "@types/react": "^16.9.55", 23 | "@types/react-dom": "^16.9.9", 24 | "@typescript-eslint/eslint-plugin": "^4.0.0", 25 | "@typescript-eslint/parser": "^4.0.0", 26 | "babel-eslint": "^10.0.0", 27 | "dts-cli": "^0.17.1", 28 | "eslint": "^7.5.0", 29 | "eslint-config-prettier": "^8.3.0", 30 | "eslint-config-react-app": "^6.0.0", 31 | "eslint-plugin-flowtype": "^6.1.0", 32 | "eslint-plugin-import": "^2.22.0", 33 | "eslint-plugin-jsx-a11y": "^6.3.1", 34 | "eslint-plugin-prettier": "^4.0.0", 35 | "eslint-plugin-react": "^7.20.3", 36 | "eslint-plugin-react-hooks": "^4.0.8", 37 | "husky": "^4.3.0", 38 | "react": "^17.0.1", 39 | "react-dom": "^17.0.1", 40 | "size-limit": "^4.7.0", 41 | "tslib": "^2.0.3", 42 | "tweakpane": "^3.0.5", 43 | "typescript": "^4.0.5" 44 | }, 45 | "scripts": { 46 | "start": "dts watch", 47 | "build": "dts build", 48 | "test": "dts test --passWithNoTests", 49 | "lint": "dts lint", 50 | "prepare": "dts build", 51 | "size": "size-limit", 52 | "analyze": "size-limit --why" 53 | }, 54 | "husky": { 55 | "hooks": { 56 | "pre-commit": "dts lint" 57 | } 58 | }, 59 | "prettier": { 60 | "semi": false, 61 | "trailingComma": "es5", 62 | "singleQuote": true, 63 | "bracketSameLine": true, 64 | "tabWidth": 2, 65 | "printWidth": 120 66 | }, 67 | "size-limit": [ 68 | { 69 | "path": "dist/use-tweaks.cjs.production.min.js", 70 | "limit": "1 KB" 71 | }, 72 | { 73 | "path": "dist/use-tweaks.esm.js", 74 | "limit": "1 KB" 75 | } 76 | ], 77 | "dependencies": { 78 | "change-case": "^4.1.1", 79 | "get-value": "^3.0.1", 80 | "set-value": "^3.0.2" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import { SpecialInputTypes } from './types' 2 | // @ts-expect-error 3 | import get from 'get-value' 4 | // @ts-expect-error 5 | import set from 'set-value' 6 | import { Schema, Folder, Button, InputConstructor, TweakpaneType, Monitor } from './types' 7 | import { noCase } from 'change-case' 8 | import { ButtonApi, InputBindingApi, InputParams, SeparatorApi } from '@tweakpane/core' 9 | 10 | function transformSettings(settings: InputParams) { 11 | if (!('options' in settings)) return settings 12 | 13 | if (Array.isArray(settings.options)) { 14 | settings.options = settings.options.reduce((acc, option) => ({ ...acc, [option]: option }), {}) 15 | } 16 | return settings 17 | } 18 | 19 | type Disposable = TweakpaneType | ButtonApi | SeparatorApi | InputBindingApi 20 | 21 | // DATA will be in the form of 22 | // DATA = { 23 | // root : { 24 | // inputs: { input1: value1, input2: value2, ...}, 25 | // folder1 : { 26 | // inputs: { input3: value3, ...} 27 | // folder11 : { ... } 28 | // }, 29 | // folder 2 : { ... } 30 | // ... 31 | // } 32 | // } 33 | const DATA: any = { root: {} } 34 | 35 | // this function traverses the schema and sets the initial input values. 36 | // - if the global DATA object already holds a key matching the schema input, 37 | // then the DATA object key value is used as the initial value. 38 | // - if the global DATA object key is empty, then the DATA object key is 39 | // initialized with the schema value. 40 | export function getData(schema: Schema, rootPath: string) { 41 | const data: Record = Object.entries(schema).reduce((accValues, [key, input]) => { 42 | // the path to the inputs of object in nested folders 43 | // we use set and get to access paths such as 44 | // DATA.global.folder.nestedFolder.inputs 45 | let INPUTS = get(DATA, `${rootPath}.inputs`) 46 | // if INPUTS doesn't exist it means that the folder doesn't exist yet, 47 | // therefore we need to initialize it first 48 | if (!INPUTS) { 49 | INPUTS = {} 50 | set(DATA, `${rootPath}.inputs`, INPUTS) 51 | } 52 | 53 | if (typeof input === 'object') { 54 | // Handles any tweakpane object that's not an actual Input 55 | if ('type' in input) { 56 | // if the input type is a Folder, then we recursively add the folder schema 57 | if (input.type === SpecialInputTypes.FOLDER) { 58 | const { title, schema } = input as Folder 59 | return { ...accValues, ...getData(schema, `${rootPath}.${title}`) } 60 | } 61 | return { ...accValues } 62 | } 63 | // if the input is an actual value then we get its value from the 64 | // DATA object, and if it isn't set, we set it to the schema value 65 | else if ('value' in input) { 66 | // input is shaped as in input = { value: value, ...settings} 67 | INPUTS[key] = INPUTS[key] ?? (input as InputConstructor).value 68 | } else { 69 | // input is an object but is shaped as in input = { x: 0, y: 0 } 70 | INPUTS[key] = INPUTS[key] ?? input 71 | } 72 | return { ...accValues, [key]: INPUTS[key] } 73 | } 74 | // same as above, only this time the input is shaped as in { key: value } 75 | // instead of { key: { value: value } } 76 | INPUTS[key] = INPUTS[key] ?? input 77 | return { ...accValues, [key]: INPUTS[key] } 78 | }, {}) 79 | 80 | return data 81 | } 82 | 83 | // this function acts similarly to the getData function, only 84 | // this time the DATA object should be fully initialized, therefore 85 | // we read its values are used to initialize Tweakpane. 86 | // It also returns an array of top-level panes that will need to be disposed 87 | // when the component is unmounted. Note that we only need top-level panes 88 | // as nested panes will be disposed when their parents are. 89 | export function buildPane( 90 | schema: Schema, 91 | rootPath: string, 92 | setValue: (key: string, value: unknown) => void, 93 | rootPane: TweakpaneType 94 | ) { 95 | // nestedPanes will hold the top level folder references that 96 | // will need to be disposed in useTweaks 97 | const nestedPanes: Disposable[] = [] 98 | 99 | // we read the inputs of the nested path 100 | let INPUTS = get(DATA, `${rootPath}.inputs`) 101 | 102 | Object.entries(schema).forEach(([key, input]) => { 103 | if (typeof input === 'object') { 104 | if ('type' in input) { 105 | if (input.type === SpecialInputTypes.MONITOR) { 106 | const { title, ref, settings } = input as Monitor 107 | let monitor 108 | if (typeof ref === 'function') { 109 | const myObj = { current: ref() } 110 | const updateFn = () => (myObj.current = ref()) 111 | 112 | monitor = rootPane.addMonitor(myObj, 'current', { label: title, ...settings }).on('update', updateFn) 113 | } else if ('current' in ref) { 114 | monitor = rootPane.addMonitor(ref, 'current', { label: title, ...settings }) 115 | } else { 116 | monitor = rootPane.addMonitor(ref, title, settings) 117 | } 118 | nestedPanes.push(monitor) 119 | } else if (input.type === SpecialInputTypes.FOLDER) { 120 | // if the input is a Folder, we recursively add the folder structure 121 | // to Tweakpane 122 | const { title, settings, schema } = input as Folder 123 | const folderPane = rootPane.addFolder({ title, ...settings }) 124 | nestedPanes.push(folderPane) 125 | buildPane(schema, `${rootPath}.${title}`, setValue, folderPane) 126 | } else if (input.type === SpecialInputTypes.BUTTON) { 127 | // Input is a Button 128 | const { title, onClick } = input as Button 129 | if (typeof onClick !== 'function') throw new Error('Button onClick must be a function.') 130 | const button = rootPane.addButton({ title }).on('click', onClick) 131 | nestedPanes.push(button) 132 | } else if (input.type === SpecialInputTypes.SEPARATOR) { 133 | // Input is a separator 134 | const separator = rootPane.addSeparator() 135 | nestedPanes.push(separator) 136 | } 137 | } else { 138 | const { value, ...settings } = input as InputConstructor 139 | const _settings = value !== undefined ? transformSettings(settings) : undefined 140 | // we add the INPUTS object to Tweakpane and we listen to changes 141 | // to trigger setValue, which will set the useTweaks hook state. 142 | const pane = rootPane 143 | .addInput(INPUTS, key, { label: noCase(key), ..._settings }) 144 | .on('change', (ev) => setValue(key, ev.value)) 145 | nestedPanes.push(pane) 146 | } 147 | } else { 148 | const pane = rootPane.addInput(INPUTS, key, { label: noCase(key) }).on('change', (ev) => setValue(key, ev.value)) 149 | nestedPanes.push(pane) 150 | } 151 | }, {}) 152 | 153 | return nestedPanes 154 | } 155 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { MonitorParams } from '@tweakpane/core' 2 | import { SpecialInputTypes, Schema, Separator, Folder, Button, Monitor } from './types' 3 | 4 | let separatorCount = 0 5 | 6 | export function makeSeparator(): Record { 7 | return { 8 | [`_s_${separatorCount++}`]: { type: SpecialInputTypes.SEPARATOR }, 9 | } 10 | } 11 | 12 | export function makeFolder(title: P, schema: T, expanded = true) { 13 | return { 14 | [`_f_${title}`]: { type: SpecialInputTypes.FOLDER, title, schema, settings: { expanded } }, 15 | } as unknown as Record> 16 | } 17 | 18 | export const makeDirectory = makeFolder 19 | 20 | export function makeButton(title: string, onClick: () => void): Record { 21 | return { 22 | [`_b_${title}`]: { type: SpecialInputTypes.BUTTON, title, onClick }, 23 | } 24 | } 25 | 26 | export function makeMonitor( 27 | title: string, 28 | ref: any | React.Ref | (() => any), 29 | settings: MonitorParams 30 | ): Record { 31 | return { 32 | [`_m_${title}`]: { 33 | type: SpecialInputTypes.MONITOR, 34 | title, 35 | ref, 36 | settings, 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useTweaks' 2 | export * from './helpers' 3 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { FolderApi, InputParams, MonitorParams, RgbaColorObject, RgbColorObject } from '@tweakpane/core' 2 | import { Pane } from 'tweakpane' 3 | import { PaneConfig } from 'tweakpane/dist/types/pane/pane-config' 4 | 5 | export type TweakpaneType = Pane | FolderApi 6 | 7 | export enum SpecialInputTypes { 8 | SEPARATOR, 9 | FOLDER, 10 | BUTTON, 11 | MONITOR, 12 | } 13 | 14 | type Point2dObject = { x: number; y: number } 15 | type InputValue = boolean | number | string | Point2dObject | RgbColorObject | RgbaColorObject 16 | 17 | export type InputConstructor = InputParams & { value: InputValue } 18 | 19 | export interface Schema { 20 | [name: string]: InputValue | InputConstructor | Folder | Separator 21 | } 22 | 23 | export type Settings = Omit & { container?: React.RefObject } 24 | 25 | export interface Monitor { 26 | type: SpecialInputTypes 27 | title: string 28 | ref: any | React.Ref | (() => any) 29 | settings: MonitorParams 30 | } 31 | 32 | export interface Folder { 33 | type: SpecialInputTypes 34 | title: string 35 | schema: T 36 | settings: { expanded: boolean } 37 | } 38 | 39 | export interface Separator { 40 | type: SpecialInputTypes 41 | } 42 | 43 | export interface Button { 44 | type: SpecialInputTypes 45 | title: string 46 | onClick: () => void 47 | } 48 | 49 | type Join = '' extends P ? { [i in K]: T[K] } : P 50 | 51 | // can probably be optimized ¯\_(ツ)_/¯ 52 | type Leaves = { 53 | 0: T extends { schema: any } ? Join> : never 54 | 1: T extends { value: any } ? { [i in P]: T['value'] } : never 55 | 2: never 56 | 3: { [i in P]: T } 57 | 4: { [K in keyof T]: Join> }[keyof T] 58 | 5: '' 59 | }[T extends Folder 60 | ? 0 61 | : T extends InputConstructor 62 | ? 1 63 | : T extends Separator | Button 64 | ? 2 65 | : T extends object 66 | ? T extends InputValue 67 | ? 3 68 | : 4 69 | : 5] 70 | 71 | /** 72 | * It does nothing but beautify union type 73 | * 74 | * ``` 75 | * type A = { a: 'a' } & { b: 'b' } // { a: 'a' } & { b: 'b' } 76 | * type B = Id<{ a: 'a' } & { b: 'b' }> // { a: 'a', b: 'b' } 77 | * ``` 78 | */ 79 | type Id = T extends infer TT ? { [k in keyof TT]: TT[k] } : never 80 | 81 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never 82 | 83 | export type UseTweaksValues = Id>> 84 | 85 | /* 86 | function useTweaks(schema: T): UseTweaksValues { 87 | // @ts-ignore 88 | return schema 89 | } 90 | const b = useTweaks({ 91 | b: 3, 92 | _2323: { type: SpecialInputTypes.SEPARATOR }, 93 | h: { value: 32, min: 0 }, 94 | _31: { 95 | type: SpecialInputTypes.FOLDER, 96 | title: 'folder2', 97 | schema: { 98 | d: 'al', 99 | f: 3, 100 | position: { value: { x: 0, y: 0 }, min: { x: -1, y: -1 }, max: { x: 1, y: 1 } }, 101 | color: { r: 255, g: 255, b: 255, a: 1 }, 102 | offset: { x: 50, y: 25 }, 103 | _33: { 104 | type: SpecialInputTypes.FOLDER, 105 | title: 'folder', 106 | schema: { c: { value: 'al' }, k: 4 }, 107 | settings: { expanded: false }, 108 | }, 109 | }, 110 | settings: { expanded: false }, 111 | }, 112 | }) 113 | */ 114 | -------------------------------------------------------------------------------- /src/useTweaks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useLayoutEffect, useRef } from 'react' 2 | import { Pane } from 'tweakpane' 3 | 4 | import { getData, buildPane } from './data' 5 | import { Schema, Settings, UseTweaksValues } from './types' 6 | 7 | let ROOTPANE: Pane | undefined 8 | 9 | export function useTweaks(schema: T, settings?: Settings): UseTweaksValues 10 | export function useTweaks(name: string, schema: T, settings?: Settings): UseTweaksValues 11 | export function useTweaks( 12 | nameOrSchema: string | T, 13 | schemaOrSettings?: T | Settings | undefined, 14 | settings?: Settings 15 | ): UseTweaksValues { 16 | const _name = typeof nameOrSchema === 'string' ? nameOrSchema : undefined 17 | const _rootKey = typeof nameOrSchema === 'string' ? 'root.' + nameOrSchema : 'root' 18 | const _settings = useRef(typeof nameOrSchema === 'string' ? settings : (schemaOrSettings as Settings)) 19 | const _schema = useRef(typeof nameOrSchema === 'string' ? (schemaOrSettings as T) : nameOrSchema) 20 | 21 | const [data, set] = useState(() => getData(_schema.current, _rootKey)) 22 | 23 | useLayoutEffect(() => { 24 | ROOTPANE = ROOTPANE || new Pane({ ..._settings.current, container: _settings.current?.container?.current! }) 25 | const isRoot = _name === undefined 26 | const _pane = _name ? ROOTPANE.addFolder({ title: _name }) : ROOTPANE 27 | const setValue = (key: string, value: unknown) => set((data) => ({ ...data, [key]: value })) 28 | const disposablePanes = buildPane(_schema.current, _rootKey, setValue, _pane) 29 | 30 | return () => { 31 | if (!isRoot) _pane.dispose() 32 | // we only need to dispose the parentFolder 33 | else disposablePanes.forEach((d) => d.dispose()) 34 | } 35 | }, [_name, _rootKey]) 36 | 37 | return data as UseTweaksValues 38 | } 39 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function uuid(): string { 2 | return `${Math.floor((new Date().getTime() * Math.random()) / 1000)}` 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // interop between ESM and CJS modules. Recommended by TS 25 | "esModuleInterop": true, 26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 27 | "skipLibCheck": true, 28 | // error out if import and file system have a casing mismatch. Recommended by TS 29 | "forceConsistentCasingInFileNames": true, 30 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 31 | "noEmit": true, 32 | } 33 | } 34 | --------------------------------------------------------------------------------