From 366c478ab5e61be52e6e2205274e4171920dad75 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 2 May 2024 17:04:00 +0800 Subject: [PATCH] Gradient slider demo --- docs/pages/experiments/slider-controlled.tsx | 6 +- docs/pages/experiments/slider-gradient.tsx | 414 ++++++++++++++++++ .../mui-base/src/useSlider2/useSlider2.ts | 24 +- packages/mui-base/src/useSlider2/utils.ts | 22 + 4 files changed, 440 insertions(+), 26 deletions(-) create mode 100644 docs/pages/experiments/slider-gradient.tsx diff --git a/docs/pages/experiments/slider-controlled.tsx b/docs/pages/experiments/slider-controlled.tsx index 06de5ca31a..96bea6e7ca 100644 --- a/docs/pages/experiments/slider-controlled.tsx +++ b/docs/pages/experiments/slider-controlled.tsx @@ -56,7 +56,7 @@ export default function App() { > }> - + @@ -70,8 +70,8 @@ export default function App() { > }> - - + + diff --git a/docs/pages/experiments/slider-gradient.tsx b/docs/pages/experiments/slider-gradient.tsx new file mode 100644 index 0000000000..9758c86781 --- /dev/null +++ b/docs/pages/experiments/slider-gradient.tsx @@ -0,0 +1,414 @@ +import * as React from 'react'; +import { alpha, hexToRgb, decomposeColor, recomposeColor, rgbToHex } from '@mui/system'; +import * as Slider from '@base_ui/react/Slider2'; +import { percentToValue, roundValueToStep } from '@base_ui/react/useSlider2/utils'; +import { clamp } from '@base_ui/react/utils/clamp'; +import { BaseUIEvent } from '@base_ui/react/utils/BaseUI.types'; + +type Stop = { + color: string; + position: number; +}; + +const INITIAL_VALUES: Stop[] = [ + { color: '#833ab4', position: 0 }, + { color: '#fd1d1d', position: 50 }, + { color: '#fcb045', position: 100 }, +]; + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +export default function App() { + const trackDefaultPreventedRef = React.useRef(false); + const trackRef = React.useRef(null); + + const [values, setValues] = React.useState(INITIAL_VALUES); + const [openThumbIndex, setOpenThumbIndex] = React.useState(0); + + const thumbInputRef = React.useRef([]); + const activeStopRef = React.useRef(null); + const isDraggingRef = React.useRef(false); + + const insertNewValue = (newPosition: number) => { + // console.log('insertNewValue position:', newPosition) + + const newIndex = [...values.map(val => val.position), newPosition].sort((a, b) => a - b).indexOf(newPosition); + // console.log('newIndex:', newIndex) + + const newValue = { color: null, position: newPosition }; + const newValues = [...values, newValue].sort((a, b) => a.position - b.position); + + const floor = newValues[newIndex - 1]; + const ceiling = newValues[newIndex + 1]; + + const distance = Math.abs(ceiling.position - floor.position) + + const percentage = (newPosition - floor.position) / distance; + + const { values: floorColor } = decomposeColor(hexToRgb((floor as Stop).color)) + const { values: ceilingColor } = decomposeColor(hexToRgb((ceiling as Stop).color)) + + // console.log('floor color', floorColor); + // console.log('ceiling color', ceilingColor); + // console.log('percentage', percentage); + + const newColor = recomposeColor({ type: 'rgb', values: [ + floorColor[0] * (1 - percentage) + ceilingColor[0] * percentage, + floorColor[1] * (1 - percentage) + ceilingColor[1] * percentage, + floorColor[2] * (1 - percentage) + ceilingColor[2] * percentage, + ] }) + // console.log('newColor', rgbToHex(newColor)) + + const finalValues = [...values, { + color: rgbToHex(newColor), + position: newPosition, + }].sort((a, b) => a.position - b.position); + + setValues(finalValues); + }; + + const removeValueByIndex = (index: number) => { + // console.log('remove by index:', index); + + const newValues = values.filter((_v, i) => i !== index); + + setValues(newValues); + + const { current: prevRefs } = thumbInputRef; + const newRefs = prevRefs.filter((_r, i) => i !== index); + + thumbInputRef.current = newRefs; + }; + + const handleValueChange = (newValue: number | number[], activeThumbIndex: number) => { + if (!Array.isArray(newValue)) { + console.error('array only!') + return; + } + + const activeStopColor = activeStopRef.current?.color ?? null; + // FIXME: bug happens if activeStopColor appears twice or more + const valuesWithoutActiveStop = values.filter(val => val.color !== activeStopColor); + // console.log('valuesWithoutActiveStop', JSON.stringify(valuesWithoutActiveStop)) + // console.log('newThumbIndex', activeThumbIndex); + + const newValues = [ + ...valuesWithoutActiveStop, + { + ...activeStopRef.current, + position: newValue[activeThumbIndex] + }, + ].sort((a, b) => a.position - b.position); + + // console.log('handleValueChange', newValues); + // @ts-ignore + setValues(newValues); + } + + const handlePointerDown = (event: BaseUIEvent) => { + if (event.target === trackRef.current) { + event.preventBaseUIHandler(); + trackDefaultPreventedRef.current = true; + } + }; + + const handlePointerUp = (event: BaseUIEvent) => { + if (trackDefaultPreventedRef.current === true) { + trackDefaultPreventedRef.current = false; + // console.log('offsetX/Y', event.nativeEvent.offsetX, event.nativeEvent.offsetY); + // console.log('clientX/Y', event.nativeEvent.clientX, event.nativeEvent.clientY); + const { current: track } = trackRef; + const { width, left } = track!.getBoundingClientRect(); + + const percent = (event.nativeEvent.offsetX - left) / width; + + let newValue = percentToValue(percent, 0, 100); + newValue = roundValueToStep(newValue, 1, 0); + newValue = clamp(newValue, 0, 100); + // console.log('onPointerUp insertNewValue:', newValue); + insertNewValue(newValue); + } + }; + + const gradient = `linear-gradient(to right ${values.reduce((acc, value) => { + const { color, position } = value; + return `${acc}, ${color} ${position}%`; + }, '')})`.trim(); + + return ( +
+ position)} + onValueChange={handleValueChange} + > +
background: {gradient}
+ } + ref={trackRef} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} + style={{ + background: gradient, + }} + > + {values.map(({ color }, index) => ( + >) => { + const currentIndex = Number(event.target.dataset.index); + if (Number.isInteger(currentIndex)) { + setOpenThumbIndex(currentIndex); + if (isDraggingRef.current === false) { + activeStopRef.current = values[currentIndex]; + } + } + }} + onBlur={() => { + if (isDraggingRef.current === false) { + activeStopRef.current = null; + } + }} + onPointerDown={event => { + isDraggingRef.current = true; + const currentIndex = Number(event.currentTarget.dataset.index); + // console.log('currentStop', values[currentIndex]) + if (Number.isInteger(currentIndex)) { + activeStopRef.current = values[currentIndex]; + } + }} + onPointerUp={() => { + isDraggingRef.current = false; + activeStopRef.current = null; + }} + ref={(node: HTMLElement | null) => { + if (node) { + thumbInputRef.current[index] = node; + } + }} + style={{ + backgroundColor: color, + }} + /> + ))} + +
+ +
+
+ Edit selected color +
+ +
+ Stops + {values.map(({ color, position }, index) => { + const setActive = () => setOpenThumbIndex(index); + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ { + const newValues = values.map((val, i) => { + if (i === index) { + return { + ...val, + color: event.target.value, + } + } + return val; + }) + setValues(newValues) + }} + /> + + + +
+ ); + })} +
+
+ +
+ ); +} + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +function Styles() { + const isDarkMode = false; + return ( + + ); +} diff --git a/packages/mui-base/src/useSlider2/useSlider2.ts b/packages/mui-base/src/useSlider2/useSlider2.ts index c7edf9a818..a4b3993cad 100644 --- a/packages/mui-base/src/useSlider2/useSlider2.ts +++ b/packages/mui-base/src/useSlider2/useSlider2.ts @@ -8,7 +8,7 @@ import { useControlled } from '../utils/useControlled'; import { useEventCallback } from '../utils/useEventCallback'; import { useForkRef } from '../utils/useForkRef'; import { useCompoundParent } from '../useCompound'; -import { valueToPercent } from './utils'; +import { percentToValue, roundValueToStep, valueToPercent } from './utils'; import { Mark, UseSliderParameters, UseSliderReturnValue } from './useSlider.types'; import { ThumbMetadata } from './useSliderThumb.types'; @@ -73,28 +73,6 @@ function focusThumb({ } } -function getDecimalPrecision(num: number) { - // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. - // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. - if (Math.abs(num) < 1) { - const parts = num.toExponential().split('e-'); - const matissaDecimalPart = parts[0].split('.')[1]; - return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); - } - - const decimalPart = num.toString().split('.')[1]; - return decimalPart ? decimalPart.length : 0; -} - -function percentToValue(percent: number, min: number, max: number) { - return (max - min) * percent + min; -} - -function roundValueToStep(value: number, step: number, min: number) { - const nearest = Math.round((value - min) / step) * step + min; - return Number(nearest.toFixed(getDecimalPrecision(step))); -} - function setValueIndex({ values, newValue, diff --git a/packages/mui-base/src/useSlider2/utils.ts b/packages/mui-base/src/useSlider2/utils.ts index 9886ee07f2..95927f20ba 100644 --- a/packages/mui-base/src/useSlider2/utils.ts +++ b/packages/mui-base/src/useSlider2/utils.ts @@ -1,3 +1,25 @@ +function getDecimalPrecision(num: number) { + // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. + // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. + if (Math.abs(num) < 1) { + const parts = num.toExponential().split('e-'); + const matissaDecimalPart = parts[0].split('.')[1]; + return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); + } + + const decimalPart = num.toString().split('.')[1]; + return decimalPart ? decimalPart.length : 0; +} + +export function percentToValue(percent: number, min: number, max: number) { + return (max - min) * percent + min; +} + +export function roundValueToStep(value: number, step: number, min: number) { + const nearest = Math.round((value - min) / step) * step + min; + return Number(nearest.toFixed(getDecimalPrecision(step))); +} + export function valueToPercent(value: number, min: number, max: number) { return ((value - min) * 100) / (max - min); }