Skip to content

Commit

Permalink
Control Panel | Knob Widget (#1132)
Browse files Browse the repository at this point in the history
* remove fp-ts

* refactor widget update logic into a hook

* improve file upload config form

* make it easier to add new widgets

* more refactor

* better typing

* it kinda works

* fix skipping bug

* require step to be positive

* implement step

* cleanup

* move some stuff out

* simplify code

* properly handle floating points (#1133)
  • Loading branch information
39bytes authored Mar 13, 2024
1 parent 0cc216a commit 2a9bd5e
Show file tree
Hide file tree
Showing 22 changed files with 593 additions and 257 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"electron-updater": "^6.1.8",
"electron-vite": "2.1.0",
"file-saver": "^2.0.5",
"fp-ts": "^2.16.3",
"html-to-image": "^1.11.11",
"http-proxy-middleware": "^2.0.6",
"immer": "^10.0.4",
Expand Down
7 changes: 0 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 6 additions & 22 deletions src/renderer/components/controls/checkbox-node.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
import { useControlBlock } from "@/renderer/hooks/useControlBlock";
import { WidgetProps } from "@/renderer/types/control";
import { toast } from "sonner";
import { Checkbox } from "@/renderer/components/ui/checkbox";
import { CheckedState } from "@radix-ui/react-checkbox";
import WidgetLabel from "@/renderer/components/common/widget-label";
import { useControl } from "@/renderer/hooks/useControl";

export const CheckboxNode = ({ id, data }: WidgetProps) => {
const { block, updateBlockParameter } = useControlBlock(data.blockId);
if (!block) {
const control = useControl(data);
if (!control) {
return <div className="text-2xl text-red-500">NOT FOUND</div>;
}

const name = block.data.label;
const paramVal = block.data.ctrls[data.blockParameter].value;

const handleChange = (state: CheckedState) => {
const res = updateBlockParameter(
block.id,
data.blockParameter,
Boolean(state.valueOf()),
);
if (res.isErr()) {
toast.error("Error updating block parameter", {
description: res.error.message,
});
}
};
const { name, value, onValueChange } = control;

return (
<div className="flex flex-col items-center rounded-md">
Expand All @@ -37,8 +21,8 @@ export const CheckboxNode = ({ id, data }: WidgetProps) => {
<div className="py-1" />
<Checkbox
className="nodrag h-6 w-6"
checked={paramVal as boolean}
onCheckedChange={handleChange}
checked={value as boolean}
onCheckedChange={(state) => onValueChange(Boolean(state.valueOf()))}
/>
</div>
);
Expand Down
23 changes: 6 additions & 17 deletions src/renderer/components/controls/combobox-node.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
import { useControlBlock } from "@/renderer/hooks/useControlBlock";
import { WidgetProps } from "@/renderer/types/control";
import { toast } from "sonner";
import { Combobox } from "@/renderer/components/ui/combobox";
import { useControl } from "@/renderer/hooks/useControl";

export const ComboboxNode = ({ data }: WidgetProps) => {
const { block, updateBlockParameter } = useControlBlock(data.blockId);
if (!block) {
const control = useControl(data);
if (!control) {
return <div className="text-2xl text-red-500">NOT FOUND</div>;
}

const name = block.data.label;
const paramVal = block.data.ctrls[data.blockParameter].value;

const handleChange = (val: string) => {
const res = updateBlockParameter(block.id, data.blockParameter, val);
if (res.isErr()) {
toast.error("Error updating block parameter", {
description: res.error.message,
});
}
};
const { block, name, value, onValueChange } = control;

return (
<div className="flex flex-col items-center rounded-md border p-2">
Expand All @@ -32,8 +21,8 @@ export const ComboboxNode = ({ data }: WidgetProps) => {
(v) => v.toString() ?? "",
) ?? []
}
value={paramVal?.toString() ?? ""}
onValueChange={handleChange}
value={value?.toString() ?? ""}
onValueChange={onValueChange}
valueSelector={(v) => v}
displaySelector={(v) => v}
/>
Expand Down
25 changes: 6 additions & 19 deletions src/renderer/components/controls/file-upload-node.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import { useControlBlock } from "@/renderer/hooks/useControlBlock";
import { FileUploadConfig, WidgetProps } from "@/renderer/types/control";
import WidgetLabel from "@/renderer/components/common/widget-label";
import { Button } from "@/renderer/components/ui/button";
import { toast } from "sonner";
import { useControl } from "@/renderer/hooks/useControl";

export const FileUploadNode = ({ id, data }: WidgetProps<FileUploadConfig>) => {
const { block, updateBlockParameter } = useControlBlock(data.blockId);
if (!block) {
const control = useControl(data);
if (!control) {
return <div className="text-2xl text-red-500">NOT FOUND</div>;
}

const name = block.data.label;
const paramVal = block.data.ctrls[data.blockParameter].value;

const handleChange = (path: string) => {
const res = updateBlockParameter(block.id, data.blockParameter, path);
if (res.isErr()) {
toast.error("Error updating block parameter", {
description: res.error.message,
});
}
};
const { name, value, onValueChange } = control;

return (
<div className="flex flex-col items-center gap-2">
Expand All @@ -35,7 +23,6 @@ export const FileUploadNode = ({ id, data }: WidgetProps<FileUploadConfig>) => {
onClick={() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
console.log(data.config.allowedExtensions.join(","));
fileInput.accept =
data.config.allowedExtensions.length > 0
? data.config.allowedExtensions.map((v) => v.ext).join(",")
Expand All @@ -45,15 +32,15 @@ export const FileUploadNode = ({ id, data }: WidgetProps<FileUploadConfig>) => {
if (files && files.length > 0) {
const file = files[0];
const path = file.path;
handleChange(path);
onValueChange(path);
}
};
fileInput.click();
}}
>
Browse
</Button>
<div className="text-xl font-bold">{paramVal}</div>
<div className="text-xl font-bold">{value}</div>
</div>
</div>
);
Expand Down
124 changes: 124 additions & 0 deletions src/renderer/components/controls/knob-node.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { KnobConfig, WidgetProps } from "@/renderer/types/control";
import WidgetLabel from "@/renderer/components/common/widget-label";
import { useControl } from "@/renderer/hooks/useControl";
import { useRef } from "react";
import { Vector2, clamp, frac, nearestMultiple } from "@/renderer/utils/math";

const minAngle = -Math.PI / 6;
const maxAngle = (7 * Math.PI) / 6;

const angleFromValue = (value: number, min: number, max: number) => {
const angleRange = maxAngle - minAngle;
const f = frac(value, min, max);
return clamp(minAngle + angleRange * f, minAngle, maxAngle);
};

const valueFromAngle = (
angle: number,
min: number,
max: number,
step: number,
) => {
const angleRange = maxAngle - minAngle;
const valRange = max - min;
const computedVal = min + valRange * ((angle - minAngle) / angleRange);
return clamp(nearestMultiple(computedVal, step), min, max);
};

type Props = {
value: number;
min: number;
max: number;
step: number;
radius: number;
onValueChange: (value: number) => void;
};

const Knob = ({ value, min, max, step, radius, onValueChange }: Props) => {
const knobRef = useRef<HTMLDivElement>(null);
const angle = useRef(angleFromValue(value, min, max));

const handleMouseDown = (
event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
event.preventDefault();
angle.current = angleFromValue(value, min, max);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};

const handleMouseMove = (event: MouseEvent) => {
if (knobRef.current === null) {
return;
}
const rect = knobRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;

const mouseVec = new Vector2(
event.clientX - centerX,
centerY - event.clientY,
).normalized();
const newPos = mouseVec.scaled(radius);
let newAngle = Math.atan2(newPos.y, newPos.x);
if (newAngle < -Math.PI / 2) {
newAngle += 2 * Math.PI;
}

const newValue = valueFromAngle(newAngle, min, max, step);
angle.current = newAngle;

onValueChange(newValue);
};

const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};

return (
<div className="nodrag mb-4 flex flex-col items-center">
<div
className="relative cursor-pointer rounded-full border-2 bg-background"
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
ref={knobRef}
style={{
transform: `rotate(${-angleFromValue(value, min, max)}rad)`,
width: radius,
height: radius,
}}
>
<div className="absolute right-0 top-1/2 h-2 w-4 -translate-y-1/2 transform rounded-sm bg-accent1" />
</div>
<span className="text-sm text-gray-600">{value}</span>
</div>
);
};

export const KnobNode = ({ id, data }: WidgetProps<KnobConfig>) => {
const control = useControl(data);
if (!control) {
return <div className="text-2xl text-red-500">NOT FOUND</div>;
}

const { name, value, onValueChange } = control;

return (
<div className="flex flex-col items-center gap-2">
<WidgetLabel
label={data.label}
placeholder={`${name} (${data.blockParameter})`}
widgetId={id}
/>
<Knob
value={value as number}
onValueChange={onValueChange}
min={data.config.min}
max={data.config.max}
step={data.config.step}
radius={80}
/>
</div>
);
};
28 changes: 6 additions & 22 deletions src/renderer/components/controls/number-input-node.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
import { useControlBlock } from "@/renderer/hooks/useControlBlock";
import { WidgetProps } from "@/renderer/types/control";
import { toast } from "sonner";
import { NumberInput } from "@/renderer/components/common/NumberInput";
import WidgetLabel from "@/renderer/components/common/widget-label";
import { useControl } from "@/renderer/hooks/useControl";

export const NumberInputNode = ({ id, data }: WidgetProps) => {
const { block, updateBlockParameter } = useControlBlock(data.blockId);
if (!block) {
const control = useControl(data);
if (!control) {
return <div className="text-2xl text-red-500">NOT FOUND</div>;
}

const name = block.data.label;
const paramVal = block.data.ctrls[data.blockParameter].value;

const handleChange = (val: number | "") => {
const res = updateBlockParameter(
block.id,
data.blockParameter,
val === "" ? undefined : val,
);
if (res.isErr()) {
toast.error("Error updating block parameter", {
description: res.error.message,
});
}
};
const { name, value, onValueChange } = control;

return (
<div className="flex flex-col items-center gap-2">
Expand All @@ -34,8 +18,8 @@ export const NumberInputNode = ({ id, data }: WidgetProps) => {
widgetId={id}
/>
<NumberInput
value={paramVal as number}
onChange={handleChange}
value={value as number}
onChange={(val) => onValueChange(val === "" ? undefined : val)}
className="nodrag text-xl font-bold"
hideArrows
/>
Expand Down
24 changes: 6 additions & 18 deletions src/renderer/components/controls/radio-group-node.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,17 @@
import { useControlBlock } from "@/renderer/hooks/useControlBlock";
import { WidgetProps } from "@/renderer/types/control";
import { toast } from "sonner";
import { Label } from "@/renderer/components/ui/label";
import {
RadioGroup,
RadioGroupItem,
} from "@/renderer/components/ui/radio-group";
import { useControl } from "@/renderer/hooks/useControl";

export const RadioGroupNode = ({ data }: WidgetProps) => {
const { block, updateBlockParameter } = useControlBlock(data.blockId);
if (!block) {
const control = useControl(data);
if (!control) {
return <div className="text-2xl text-red-500">NOT FOUND</div>;
}

const name = block.data.label;
const paramVal = block.data.ctrls[data.blockParameter].value;

const handleChange = (val: string) => {
const res = updateBlockParameter(block.id, data.blockParameter, val);
if (res.isErr()) {
toast.error("Error updating block parameter", {
description: res.error.message,
});
}
};
const { block, name, value, onValueChange } = control;

const options =
block.data.ctrls[data.blockParameter].options?.map(
Expand All @@ -37,8 +25,8 @@ export const RadioGroupNode = ({ data }: WidgetProps) => {
</div>
<div className="py-2"></div>
<RadioGroup
value={paramVal?.toString() ?? undefined}
onValueChange={handleChange}
value={value?.toString() ?? undefined}
onValueChange={onValueChange}
>
{options.map((option) => (
<div className="flex items-center space-x-2" key={option}>
Expand Down
Loading

0 comments on commit 2a9bd5e

Please sign in to comment.