Improved slider.
All checks were successful
homepanel/HomePanel/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-02-16 02:30:20 +00:00
parent beae2ccff4
commit 7a6ec9ab33
7 changed files with 190 additions and 134 deletions

View File

@ -78,25 +78,33 @@ function Dashboard({
const { isFullscreen, toggleFullscreen } = useFullscreen(dashboardRef);
const [isEntityDashboardOpen, setIsEntityDashboardOpen] = useState(false);
const [entityDashboardEntity, setEntityDashboardEntity] = useState(null);
const entityDashboardEntityRef = useRef(null);
const [isPreview, setIsPreview] = useState(false);
// Keep ref in sync with state for use in callbacks
useEffect(() => {
entityDashboardEntityRef.current = entityDashboardEntity;
}, [entityDashboardEntity]);
// Entity subscription handler - updates local entity states from WebSocket
const handleEntityUpdate = useCallback(
(entityId, updatedEntity) => {
setEntityStates((prev) => ({
const handleEntityUpdate = useCallback((entityId, updatedEntity) => {
setEntityStates((prev) => ({
...prev,
[entityId]: {
...prev[entityId],
...updatedEntity,
},
}));
// Also update entityDashboardEntity if it matches the updated entity
// Use ref to avoid recreating callback when entityDashboardEntity changes
if (entityId === entityDashboardEntityRef.current?.entityId) {
setEntityDashboardEntity((prev) => ({
...prev,
[entityId]: {
...prev[entityId],
...updatedEntity,
},
...updatedEntity,
name: updatedEntity.name.replaceAll(title, ""),
}));
// Also update entityDashboardEntity if it matches the updated entity
if (entityId === entityDashboardEntity?.entityId) {
setEntityDashboardEntity((prev) => ({ ...prev, ...updatedEntity }));
}
},
[entityDashboardEntity],
);
}
}, []);
// Combine all entity IDs to subscribe to (from entities and slides)
const allEntityIdsToSubscribe = useMemo(() => {
@ -604,7 +612,10 @@ function Dashboard({
};
const handleOpenEntityDashboard = useCallback((entity) => {
setEntityDashboardEntity(entity);
setEntityDashboardEntity({
...entity,
name: entity.name.replaceAll(title, ""),
});
setIsEntityDashboardOpen(true);
}, []);

View File

@ -11,6 +11,14 @@
pointer-events: none;
}
.brightness-slider-unfilled {
pointer-events: none;
}
.brightness-slider-filled {
pointer-events: none;
}
.brightness-slider-handle {
transition: transform 0.05s ease-out;
}

View File

@ -3,52 +3,73 @@ import "./BrightnessSlider.css";
/**
* BrightnessSlider - Custom vertical brightness slider component
* Displays a gradient from black to the current color based on hue/saturation
* Displays a progress bar style with filled portion showing current color
* and unfilled portion showing black with 0.1 opacity
*/
export const BrightnessSlider = ({
value = 100, // 0-100 brightness value
color = { h: 0, s: 100 }, // HS color for gradient
color = { h: 0, s: 100 }, // HS color for filled portion
onChange,
height = 300,
height = 300, // Can be number (px) or CSS string (e.g., "30vw")
width = 60,
handleRadius = 30,
borderWidth = 2,
borderColor = "rgba(255, 255, 255, 0.359)",
}) => {
const containerRef = useRef(null);
const isDragging = useRef(false);
const [handlePosition, setHandlePosition] = useState(0);
const [currentValue, setCurrentValue] = useState(value);
const [measuredHeight, setMeasuredHeight] = useState(0);
const sliderPadding = handleRadius;
const effectiveHeight = height - sliderPadding * 2;
// Constantly measure the container height like HSPicker does
useEffect(() => {
if (!containerRef.current) return;
// Convert value (0-100) to Y position
const valueToPosition = useCallback(
(val) => {
// Invert: 100 = top, 0 = bottom
return sliderPadding + (1 - val / 100) * effectiveHeight;
},
[sliderPadding, effectiveHeight]
);
let lastHeight = containerRef.current.clientHeight;
setMeasuredHeight(lastHeight);
// Poll for size changes
const checkSize = () => {
if (containerRef.current) {
const currentHeight = containerRef.current.clientHeight;
if (currentHeight !== lastHeight) {
lastHeight = currentHeight;
setMeasuredHeight(currentHeight);
}
}
};
// Start polling after a short delay to ensure element is rendered
const startId = setTimeout(() => {
checkSize(); // Initial check
}, 100);
const intervalId = setInterval(checkSize, 100);
return () => {
clearTimeout(startId);
clearInterval(intervalId);
};
}, []);
const sliderPadding = 0;
const effectiveHeight = measuredHeight - sliderPadding * 2;
// Convert Y position to value (0-100)
const positionToValue = useCallback(
(y) => {
const clampedY = Math.max(sliderPadding, Math.min(y, height - sliderPadding));
const clampedY = Math.max(
sliderPadding,
Math.min(y, measuredHeight - sliderPadding),
);
const normalizedY = (clampedY - sliderPadding) / effectiveHeight;
// Invert: top = 100, bottom = 0
return Math.round((1 - normalizedY) * 100);
},
[sliderPadding, effectiveHeight, height]
[sliderPadding, effectiveHeight, measuredHeight],
);
// Update handle position when value prop changes
// Update current value when value prop changes
useEffect(() => {
const pos = valueToPosition(value);
setHandlePosition(pos);
setCurrentValue(value);
}, [value, valueToPosition]);
}, [value]);
// Get coordinates from mouse/touch event
const getEventCoordinates = useCallback((e) => {
@ -72,16 +93,13 @@ export const BrightnessSlider = ({
const handleValueSelect = useCallback(
(y) => {
const newValue = positionToValue(y);
const pos = valueToPosition(newValue);
setHandlePosition(pos);
setCurrentValue(newValue);
if (onChange) {
onChange(newValue);
}
},
[positionToValue, valueToPosition, onChange]
[positionToValue, onChange],
);
// Mouse/touch event handlers
@ -92,7 +110,7 @@ export const BrightnessSlider = ({
const coords = getEventCoordinates(e);
handleValueSelect(coords.y);
},
[getEventCoordinates, handleValueSelect]
[getEventCoordinates, handleValueSelect],
);
const handleMove = useCallback(
@ -102,7 +120,7 @@ export const BrightnessSlider = ({
const coords = getEventCoordinates(e);
handleValueSelect(coords.y);
},
[getEventCoordinates, handleValueSelect]
[getEventCoordinates, handleValueSelect],
);
const handleEnd = useCallback(() => {
@ -126,7 +144,9 @@ export const BrightnessSlider = ({
document.addEventListener("mouseup", handleGlobalEnd);
// Touch events
document.addEventListener("touchmove", handleGlobalMove, { passive: false });
document.addEventListener("touchmove", handleGlobalMove, {
passive: false,
});
document.addEventListener("touchend", handleGlobalEnd);
document.addEventListener("touchcancel", handleGlobalEnd);
@ -180,56 +200,16 @@ export const BrightnessSlider = ({
};
};
// Get HSV to RGB for handle color (includes brightness/value)
const hsvToRgb = (h, s, v) => {
const sNorm = s / 100;
const vNorm = v / 100;
const c = vNorm * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = vNorm - c;
// Color for the filled portion (current color at full brightness)
const filledRgb = hsToRgb(color.h, color.s);
const filledColor = `rgb(${filledRgb.r}, ${filledRgb.g}, ${filledRgb.b})`;
let r, g, b;
if (h < 60) {
r = c;
g = x;
b = 0;
} else if (h < 120) {
r = x;
g = c;
b = 0;
} else if (h < 180) {
r = 0;
g = c;
b = x;
} else if (h < 240) {
r = 0;
g = x;
b = c;
} else if (h < 300) {
r = x;
g = 0;
b = c;
} else {
r = c;
g = 0;
b = x;
}
// Calculate filled height based on current value (0-100)
const filledHeight = (currentValue / 100) * effectiveHeight;
const unfilledHeight = effectiveHeight - filledHeight;
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
};
};
const gradientRgb = hsToRgb(color.h, color.s);
const gradientColor = `rgb(${gradientRgb.r}, ${gradientRgb.g}, ${gradientRgb.b})`;
const handleRgb = hsvToRgb(color.h, color.s, currentValue);
const handleColorStyle = `rgb(${handleRgb.r}, ${handleRgb.g}, ${handleRgb.b})`;
// Gradient from color (top) to black (bottom)
const gradientStyle = `linear-gradient(to bottom, ${gradientColor} 0%, rgb(0, 0, 0) 100%)`;
const trackWidth = 60;
const trackBorderRadius = 30;
return (
<div
@ -243,39 +223,44 @@ export const BrightnessSlider = ({
onMouseDown={handleStart}
onTouchStart={handleStart}
>
{/* Slider track with gradient */}
{/* Slider track container */}
<div
className="brightness-slider-track"
style={{
position: "absolute",
left: (width - 30) / 2,
left: (width - trackWidth) / 2,
top: sliderPadding,
width: 30,
width: trackWidth,
height: effectiveHeight,
borderRadius: 15,
background: gradientStyle,
boxShadow: "inset 0 0 3px rgba(0, 0, 0, 0.3)",
borderRadius: trackBorderRadius,
overflow: "hidden",
}}
/>
{/* Handle/cursor */}
<div
className="brightness-slider-handle"
style={{
position: "absolute",
left: (width - handleRadius * 2) / 2,
top: handlePosition - handleRadius,
width: handleRadius * 2,
height: handleRadius * 2,
borderRadius: "50%",
backgroundColor: handleColorStyle,
border: `${borderWidth}px solid ${borderColor}`,
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.3)",
cursor: "grab",
pointerEvents: "none",
boxSizing: "border-box",
}}
/>
>
{/* Unfilled portion (top) - black with 0.1 opacity */}
<div
className="brightness-slider-unfilled"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: unfilledHeight,
backgroundColor: "rgba(0, 0, 0, 0.1)",
}}
/>
{/* Filled portion (bottom) - current color */}
<div
className="brightness-slider-filled"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: filledHeight,
backgroundColor: filledColor,
}}
/>
</div>
</div>
);
};

View File

@ -11,6 +11,7 @@
display: flex;
flex-direction: column;
justify-content: center;
min-width: 380px;
}
.entity-dashboard-info-title {

View File

@ -23,7 +23,7 @@
/* Brightness slider styling */
.light-entity-dashboard-brightness-slider {
margin-left: 5vw;
margin-left: 2.5vw;
}
/* iro.js slider handle styling */

View File

@ -16,7 +16,7 @@ export const LightEntityDashboard = ({ entity }) => {
const [localBrightness, setLocalBrightness] = useState(255);
const brightnessTimeoutRef = useRef(null);
const colorTimeoutRef = useRef(null);
const DASHBOARD_HEIGHT = 460;
const DASHBOARD_HEIGHT = "30vw";
const isUpdatingFromEntity = useRef(false);
if (!entity) return null;

View File

@ -1,4 +1,10 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import "./HSPicker.css";
// Import the color circle SVG as a React component
@ -23,12 +29,47 @@ export const HSPicker = ({
const pendingUpdate = useRef(null);
const [handlePosition, setHandlePosition] = useState({ x: 0, y: 0 });
const [currentColor, setCurrentColor] = useState(color);
const [measuredSize, setMeasuredSize] = useState(0);
// Constantly measure the container size like SlideView does
useEffect(() => {
if (!containerRef.current) return;
let lastSize = containerRef.current.clientWidth;
setMeasuredSize(lastSize);
// Poll for size changes
const checkSize = () => {
if (containerRef.current) {
const currentSize = containerRef.current.clientWidth;
if (currentSize !== lastSize) {
lastSize = currentSize;
setMeasuredSize(currentSize);
}
}
};
// Start polling after a short delay to ensure element is rendered
const startId = setTimeout(() => {
checkSize(); // Initial check
}, 100);
const intervalId = setInterval(checkSize, 100);
return () => {
clearTimeout(startId);
clearInterval(intervalId);
};
}, []);
// Memoize computed values
const { radius, effectiveRadius } = useMemo(() => ({
radius: size / 2,
effectiveRadius: size / 2 - handleRadius,
}), [size, handleRadius]);
const { radius, effectiveRadius } = useMemo(
() => ({
radius: measuredSize / 2,
effectiveRadius: measuredSize / 2 - handleRadius,
}),
[measuredSize, handleRadius],
);
// Convert HS to position on the wheel
const hsToPosition = useCallback(
@ -153,7 +194,14 @@ export const HSPicker = ({
});
}
},
[radius, effectiveRadius, positionToHS, hsToPosition, onChange, handleRadius],
[
radius,
effectiveRadius,
positionToHS,
hsToPosition,
onChange,
handleRadius,
],
);
// Mouse/touch event handlers
@ -177,13 +225,16 @@ export const HSPicker = ({
[getEventCoordinates, handleColorSelect],
);
const handleEnd = useCallback((e) => {
if (isDragging.current && containerRef.current) {
const coords = getEventCoordinates(e);
handleColorSelect(coords.x, coords.y, true);
}
isDragging.current = false;
}, [getEventCoordinates, handleColorSelect]);
const handleEnd = useCallback(
(e) => {
if (isDragging.current && containerRef.current) {
const coords = getEventCoordinates(e);
handleColorSelect(coords.x, coords.y, true);
}
isDragging.current = false;
},
[getEventCoordinates, handleColorSelect],
);
// Add global event listeners for drag
useEffect(() => {