364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { Listbox, Transition } from '@headlessui/react'
|
|
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid'
|
|
|
|
import { useRender } from 'src/components/IdeWrapper/useRender'
|
|
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
|
import { Switch } from '@headlessui/react'
|
|
import Svg from 'src/components/Svg/Svg'
|
|
import {
|
|
CadhubStringParam,
|
|
CadhubBooleanParam,
|
|
CadhubNumberParam,
|
|
CadhubStringChoiceParam,
|
|
CadhubNumberChoiceParam,
|
|
} from './customizerConverter'
|
|
|
|
const Customizer = () => {
|
|
const [shouldLiveUpdate, setShouldLiveUpdate] = React.useState(false)
|
|
const { state, thunkDispatch } = useIdeContext()
|
|
const isOpen = state.isCustomizerOpen
|
|
const customizerParams = state?.customizerParams
|
|
const currentParameters = state?.currentParameters || {}
|
|
const handleRender = useRender()
|
|
const toggleOpen = () => {
|
|
thunkDispatch({ type: 'setCustomizerOpenState', payload: !isOpen })
|
|
if (state.viewerContext === 'ide') {
|
|
// don't re-render on open/close in the project profile
|
|
setTimeout(() => handleRender())
|
|
}
|
|
}
|
|
const handleReset = () => {
|
|
thunkDispatch({ type: 'resetCustomizer' })
|
|
setTimeout(() => handleRender(true))
|
|
}
|
|
|
|
const updateCustomizerParam = (paramName: string, paramValue: any) => {
|
|
const payload = {
|
|
...currentParameters,
|
|
[paramName]: paramValue,
|
|
}
|
|
thunkDispatch({ type: 'setCurrentCustomizerParams', payload })
|
|
shouldLiveUpdate && setTimeout(() => handleRender())
|
|
}
|
|
if (!customizerParams?.length) return null
|
|
return (
|
|
<div
|
|
className={`absolute inset-x-0 bottom-0 bg-ch-gray-600 bg-opacity-60 text-ch-gray-300 text-lg font-fira-sans ${
|
|
isOpen ? 'h-full max-h-96' : ''
|
|
}`}
|
|
>
|
|
<div className="flex justify-between px-6 py-2 items-center">
|
|
<div className="grid grid-flow-col-dense gap-6 items-center">
|
|
<button className="px-2" onClick={toggleOpen}>
|
|
<Svg
|
|
name="chevron-down"
|
|
className={`h-8 w-8 ${!isOpen && 'transform rotate-180'}`}
|
|
/>
|
|
</button>
|
|
<div>Parameters</div>
|
|
</div>
|
|
{isOpen && (
|
|
<>
|
|
<div className="flex items-center">
|
|
<div className="font-fira-sans text-sm mr-4">Auto Update</div>
|
|
<Switch
|
|
checked={shouldLiveUpdate}
|
|
onChange={(newValue) => {
|
|
if (newValue) handleRender()
|
|
setShouldLiveUpdate(newValue)
|
|
}}
|
|
className={`${
|
|
shouldLiveUpdate ? 'bg-ch-purple-600' : 'bg-ch-gray-300'
|
|
} relative inline-flex items-center h-6 rounded-full w-11 mr-6`}
|
|
>
|
|
<span
|
|
className={`${
|
|
shouldLiveUpdate ? 'translate-x-6' : 'translate-x-1'
|
|
} inline-block w-4 h-4 transform bg-white rounded-full`}
|
|
/>
|
|
</Switch>
|
|
<button
|
|
className="px-4 py-1 rounded bg-ch-gray-300 text-ch-gray-600 mr-2"
|
|
onClick={handleReset}
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
className={`px-4 py-1 rounded bg-ch-gray-300 text-ch-gray-800 ${
|
|
shouldLiveUpdate && 'bg-opacity-30 cursor-default'
|
|
}`}
|
|
onClick={() => handleRender()}
|
|
disabled={shouldLiveUpdate}
|
|
>
|
|
Update
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`${isOpen ? 'h-full pb-32' : 'h-0'} overflow-y-auto px-12`}
|
|
>
|
|
<div>
|
|
{customizerParams.map((param, index) => {
|
|
const otherProps = {
|
|
value: currentParameters[param.name],
|
|
onChange: (value) =>
|
|
updateCustomizerParam(
|
|
param.name,
|
|
param.type == 'number' ? Number(value) : value
|
|
),
|
|
}
|
|
if (
|
|
param.input === 'choice-string' ||
|
|
param.input === 'choice-number'
|
|
) {
|
|
return <ChoiceParam key={index} param={param} {...otherProps} />
|
|
} else if (param.input === 'default-string') {
|
|
return <StringParam key={index} param={param} {...otherProps} />
|
|
} else if (param.input === 'default-number') {
|
|
return <NumberParam key={index} param={param} {...otherProps} />
|
|
} else if (param.input === 'default-boolean') {
|
|
return <BooleanParam key={index} param={param} {...otherProps} />
|
|
}
|
|
return <div key={index}>{JSON.stringify(param)}</div>
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Customizer
|
|
|
|
function CustomizerParamBase({
|
|
name,
|
|
caption,
|
|
children,
|
|
}: {
|
|
name: string
|
|
caption: string
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<li
|
|
className="grid items-center my-2"
|
|
style={{ gridTemplateColumns: 'auto 16rem' }}
|
|
>
|
|
<div className=" text-sm font-fira-sans">
|
|
<div className="font-bold text-base">{name}</div>
|
|
<div>{caption}</div>
|
|
</div>
|
|
<div className="w-full">{children}</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
function BooleanParam({
|
|
param,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
param: CadhubBooleanParam
|
|
value: any
|
|
onChange: (value: any) => void
|
|
}) {
|
|
return (
|
|
<CustomizerParamBase name={param.name} caption={param.caption}>
|
|
<Switch
|
|
checked={value}
|
|
onChange={(newValue) => {
|
|
onChange(newValue)
|
|
}}
|
|
className={`${
|
|
value ? 'bg-ch-gray-300' : 'bg-ch-gray-600'
|
|
} relative inline-flex items-center h-6 rounded-full w-11 mr-6 border border-ch-gray-300`}
|
|
>
|
|
<span
|
|
className={`${
|
|
value ? 'translate-x-6' : 'translate-x-1'
|
|
} inline-block w-4 h-4 transform bg-white rounded-full`}
|
|
/>
|
|
</Switch>
|
|
</CustomizerParamBase>
|
|
)
|
|
}
|
|
|
|
function StringParam({
|
|
param,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
param: CadhubStringParam
|
|
value: any
|
|
onChange: (value: any) => void
|
|
}) {
|
|
return (
|
|
<CustomizerParamBase name={param.name} caption={param.caption}>
|
|
<input
|
|
className="bg-transparent h-8 border border-ch-gray-300 px-2 text-sm w-full"
|
|
type="text"
|
|
value={value}
|
|
placeholder={param.placeholder}
|
|
onChange={({ target }) => onChange(target?.value)}
|
|
/>
|
|
</CustomizerParamBase>
|
|
)
|
|
}
|
|
|
|
function ChoiceParam({
|
|
param,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
param: CadhubStringChoiceParam | CadhubNumberChoiceParam
|
|
value: any
|
|
onChange: (value: any) => void
|
|
}) {
|
|
return (
|
|
<CustomizerParamBase name={param.name} caption={param.caption}>
|
|
<Listbox value={value} onChange={onChange}>
|
|
<div className="relative mt-1">
|
|
<Listbox.Button className="relative w-full h-8 text-left cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 sm:text-sm border border-ch-gray-300 px-2 text-sm">
|
|
<span className="block truncate">{value}</span>
|
|
<span className="absolute inset-y-0 right-0 flex items-center pr-1 pointer-events-none">
|
|
<SelectorIcon
|
|
className="w-5 h-5 text-gray-300"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
</Listbox.Button>
|
|
<Transition
|
|
as={React.Fragment}
|
|
leave="transition ease-in duration-100"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
>
|
|
<Listbox.Options className="absolute w-full py-1 mt-1 bg-ch-gray-600 bg-opacity-80 overflow-auto text-base rounded-sm shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
{param.options.map((option, optionIdx) => (
|
|
<Listbox.Option
|
|
key={optionIdx}
|
|
className={({ active }) =>
|
|
`${
|
|
active
|
|
? 'text-ch-blue-400 bg-ch-gray-700'
|
|
: 'text-ch-gray-300'
|
|
}
|
|
cursor-default select-none relative py-2 pl-10 pr-4`
|
|
}
|
|
value={option.value}
|
|
>
|
|
{({ selected, active }) => (
|
|
<>
|
|
<span
|
|
className={`${
|
|
selected ? 'font-medium' : 'font-normal'
|
|
} block truncate`}
|
|
>
|
|
{option.name}
|
|
</span>
|
|
{selected ? (
|
|
<span
|
|
className={`${
|
|
active ? 'text-ch-blue-400' : 'text-ch-gray-300'
|
|
}
|
|
absolute inset-y-0 left-0 flex items-center pl-3`}
|
|
>
|
|
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
|
</span>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Listbox.Option>
|
|
))}
|
|
</Listbox.Options>
|
|
</Transition>
|
|
</div>
|
|
</Listbox>
|
|
</CustomizerParamBase>
|
|
)
|
|
}
|
|
|
|
function NumberParam({
|
|
param,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
param: CadhubNumberParam
|
|
value: any
|
|
onChange: (value: any) => void
|
|
}) {
|
|
const [isFocused, isFocusedSetter] = React.useState(false)
|
|
const [localValue, localValueSetter] = React.useState(value)
|
|
const [isLocked, isLockedSetter] = React.useState(false)
|
|
const [pixelsDragged, pixelsDraggedSetter] = React.useState(0)
|
|
const step = param.step || 1
|
|
const commitChange = () => {
|
|
let num = localValue
|
|
if (typeof param.step === 'number') {
|
|
num = Math.round(num / step) * step
|
|
}
|
|
if (typeof param.min === 'number') {
|
|
num = Math.max(param.min, num)
|
|
}
|
|
if (typeof param.max === 'number') {
|
|
num = Math.min(param.max, num)
|
|
}
|
|
num = Number((num || 0).toFixed(2))
|
|
localValueSetter(num)
|
|
onChange(num)
|
|
}
|
|
React.useEffect(() => {
|
|
if (!isFocused) commitChange()
|
|
}, [isFocused])
|
|
React.useEffect(() => localValueSetter(value), [value])
|
|
return (
|
|
<CustomizerParamBase name={param.name} caption={param.caption}>
|
|
<div className="flex h-8 border border-ch-gray-300">
|
|
<input
|
|
className={`bg-transparent px-2 text-sm w-full ${
|
|
(param.max && param.max < localValue) ||
|
|
(param.min && param.min > localValue)
|
|
? 'text-red-500'
|
|
: ''
|
|
}`}
|
|
type="number"
|
|
value={localValue}
|
|
onFocus={() => isFocusedSetter(true)}
|
|
onBlur={() => isFocusedSetter(false)}
|
|
onKeyDown={({ key }) => key === 'Enter' && commitChange()}
|
|
onChange={({ target }) => {
|
|
const num = Number(target?.value)
|
|
localValueSetter(num)
|
|
}}
|
|
max={param.max}
|
|
min={param.min}
|
|
step={step}
|
|
/>
|
|
<div
|
|
className="w-6 border-l border-ch-gray-500 items-center hidden md:flex"
|
|
style={{ cursor: 'ew-resize' }}
|
|
onMouseDown={({ target }) => {
|
|
isLockedSetter(true)
|
|
target?.requestPointerLock?.()
|
|
pixelsDraggedSetter(localValue)
|
|
}}
|
|
onMouseUp={() => {
|
|
isLockedSetter(false)
|
|
document?.exitPointerLock?.()
|
|
commitChange()
|
|
}}
|
|
onMouseMove={({ movementX }) => {
|
|
if (isLocked && movementX) {
|
|
pixelsDraggedSetter(pixelsDragged + (movementX * step) / 8) // one step per 8 pixels
|
|
localValueSetter(Number(pixelsDragged.toFixed(2)))
|
|
}
|
|
}}
|
|
>
|
|
<Svg className="w-6" name="switch-horizontal" />
|
|
</div>
|
|
</div>
|
|
</CustomizerParamBase>
|
|
)
|
|
}
|