diff --git a/apps/builder/app/builder/features/style-panel/sections/backgrounds/gradient-control.stories.tsx b/apps/builder/app/builder/features/style-panel/sections/backgrounds/gradient-control.stories.tsx new file mode 100644 index 000000000000..52b52fcd3a37 --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/backgrounds/gradient-control.stories.tsx @@ -0,0 +1,68 @@ +import { + parseLinearGradient, + reconstructLinearGradient, + type ParsedGradient, +} from "@webstudio-is/css-data"; +import { GradientControl } from "./gradient-control"; +import { Flex, Text } from "@webstudio-is/design-system"; +import { useState } from "react"; + +export default { + title: "Library/GradientControl", +}; + +export const GradientWithoutAngle = () => { + const gradientString = "linear-gradient(black 0%, white 100%)"; + const [gradient, setGradient] = useState(gradientString); + + return ( + + { + setGradient(reconstructLinearGradient(value)); + }} + onThumbSelected={() => {}} + /> + {gradient} + + ); +}; + +export const GradientWithAngleAndHints = () => { + const gradientString = + "linear-gradient(145deg, #ff00fa 0%, #00f497 34% 34%, #ffa800 56% 56%, #00eaff 100%)"; + const [gradient, setGradient] = useState(gradientString); + + return ( + + { + setGradient(reconstructLinearGradient(value)); + }} + onThumbSelected={() => {}} + /> + {gradient} + + ); +}; + +export const GradientWithSideOrCorner = () => { + const gradientString = "linear-gradient(to left top, blue 0%, red 100%)"; + + const [gradient, setGradient] = useState(gradientString); + + return ( + + { + setGradient(reconstructLinearGradient(value)); + }} + onThumbSelected={() => {}} + /> + {gradient} + + ); +}; diff --git a/apps/builder/app/builder/features/style-panel/sections/backgrounds/gradient-control.tsx b/apps/builder/app/builder/features/style-panel/sections/backgrounds/gradient-control.tsx new file mode 100644 index 000000000000..d24b57baf992 --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/backgrounds/gradient-control.tsx @@ -0,0 +1,299 @@ +import { toValue, UnitValue, type RgbValue } from "@webstudio-is/css-engine"; +import { Slider, Range, Thumb, Track } from "@radix-ui/react-slider"; +import { useState, useCallback } from "react"; +import { + reconstructLinearGradient, + type GradientStop, + type ParsedGradient, +} from "@webstudio-is/css-data"; +import { styled, theme, Flex, Box } from "@webstudio-is/design-system"; +import { colord, extend } from "colord"; +import mixPlugin from "colord/plugins/mix"; +import { ChevronFilledUpIcon } from "@webstudio-is/icons"; + +extend([mixPlugin]); + +type GradientControlProps = { + gradient: ParsedGradient; + onChange: (value: ParsedGradient) => void; + onThumbSelected: (index: number, stop: GradientStop) => void; +}; + +const defaultAngle: UnitValue = { + type: "unit", + value: 90, + unit: "deg", +}; + +export const GradientControl = (props: GradientControlProps) => { + const [stops, setStops] = useState>(props.gradient.stops); + const [selectedStop, setSelectedStop] = useState(); + const [isHoveredOnStop, setIsHoveredOnStop] = useState(false); + const positions = stops + .map((stop) => stop.position?.value) + .filter((item) => item !== undefined); + const hints = props.gradient.stops + .map((stop) => stop.hint?.value) + .filter((item) => item !== undefined); + const background = reconstructLinearGradient({ + stops, + sideOrCorner: props.gradient.sideOrCorner, + angle: defaultAngle, + }); + + // Every color stop should have a position asociated for us in-order to display the slider thumb. + // But when users manually enter linear-gradient from the advanced-panel. They might add something like this + // linear-gradient(to right, red, blue), or linear-gradient(150deg, red, blue 50%, yellow 50px) + // Browsers handels all these cases by following the rules of the css spec. + // https://www.w3.org/TR/css-images-4/#color-stop-fixup + // In order to handle such inputs from the advanced tab too. We need to implement the color-stop-fix-up spec during parsing. + // But for now, we are just checking if every stop has a position or not. Since the main use-case is to add gradients from ui. + // We will never run into this case of a color-stop missing a position associated with it. + const isEveryStopHasAPosition = stops.every( + (stop) => stop.position !== undefined && stop.color !== undefined + ); + + const handleValueChange = useCallback( + (newPositions: number[]) => { + const newStops: GradientStop[] = stops.map((stop, index) => ({ + ...stop, + position: { type: "unit", value: newPositions[index], unit: "%" }, + })); + + setStops(newStops); + props.onChange({ + angle: props.gradient.angle, + stops: newStops, + sideOrCorner: props.gradient.sideOrCorner, + }); + }, + [stops, props] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Backspace" && selectedStop !== undefined) { + const newStops = stops; + newStops.splice(selectedStop, 1); + setStops(newStops); + setSelectedStop(undefined); + } + }, + [stops, selectedStop] + ); + + const checkIfStopExistsAtPosition = useCallback( + ( + event: React.MouseEvent + ): { isStopExistingAtPosition: boolean; newPosition: number } => { + const sliderWidth = event.currentTarget.offsetWidth; + const clickedPosition = + event.clientX - event.currentTarget.getBoundingClientRect().left; + const newPosition = Math.ceil((clickedPosition / sliderWidth) * 100); + // The 8px buffer here is the width of the thumb. + // We don't want to add a new stop if the user clicks on the thumb. + const isStopExistingAtPosition = positions.some( + (position) => Math.abs(newPosition - position) <= 8 + ); + + return { isStopExistingAtPosition, newPosition }; + }, + [positions] + ); + + const handlePointerDown = useCallback( + (event: React.MouseEvent) => { + if (event.target === undefined || event.target === null) { + return; + } + + // radix-slider automatically brings the closest thumb to the clicked position. + // But, we want it be prevented. For adding a new color-stop where the user clicked. + // And handle the change in values only even for scrubing when the user is dragging the thumb. + const { isStopExistingAtPosition, newPosition } = + checkIfStopExistsAtPosition(event); + + if (isStopExistingAtPosition === true) { + return; + } + + // Adding a new stop when user clicks on the slider. + event.preventDefault(); + const newStopIndex = positions.findIndex( + (position) => position > newPosition + ); + const index = newStopIndex === -1 ? stops.length : newStopIndex; + const prevColor = stops[index === 0 ? 0 : index - 1].color; + const nextColor = + stops[index === positions.length ? index - 1 : index].color; + + const interpolationColor = colord(toValue(prevColor)) + .mix(colord(toValue(nextColor)), newPosition / 100) + .toRgb(); + + const newColorStop: RgbValue = { + type: "rgb", + alpha: interpolationColor.a, + r: interpolationColor.r, + g: interpolationColor.g, + b: interpolationColor.b, + }; + + const newStops: GradientStop[] = [ + ...stops.slice(0, index), + { + color: newColorStop, + position: { type: "unit", value: newPosition, unit: "%" }, + }, + ...stops.slice(index), + ]; + + setStops(newStops); + setIsHoveredOnStop(true); + props.onChange({ + angle: props.gradient.angle, + stops: newStops, + sideOrCorner: props.gradient.sideOrCorner, + }); + }, + [stops, positions, checkIfStopExistsAtPosition, props] + ); + + const handleStopSelected = useCallback( + (index: number, stop: GradientStop) => { + setSelectedStop(index); + props.onThumbSelected(index, stop); + }, + [props] + ); + + const handleMouseEnter = (event: React.MouseEvent) => { + const { isStopExistingAtPosition } = checkIfStopExistsAtPosition(event); + setIsHoveredOnStop(isStopExistingAtPosition); + }; + + if (isEveryStopHasAPosition === false) { + return; + } + + return ( + + setIsHoveredOnStop(false)} + > + + + + {stops.map((stop, index) => { + if (stop.color === undefined || stop.position === undefined) { + return; + } + + return ( + handleStopSelected(index, stop)} + > + + + ); + })} + + {/* + Hints are displayed as a chevron icon below the slider thumb. + Usually hints are used to display the behaviour of the color-stop that is preciding. + But, if we just move them along the UI. We will be basically altering the gradient itself. + Because the position of the hint is the position of the color-stop. And moving it along, might associate the hint + with a different color-stop. So, we are not allowing the user to move the hint along the slider. + + None of the tools are even displaying the hints at the moment. We are just displaying them so users can know + they are hints associated with stops if they managed to add gradient from the advanced tab. + */} + {hints.map((hint) => { + return ( + + + + ); + })} + + + ); +}; + +const SliderRoot = styled(Slider, { + position: "relative", + width: "100%", + height: theme.spacing[9], + border: `1px solid ${theme.colors.borderMain}`, + borderRadius: theme.borderRadius[3], + touchAction: "none", + userSelect: "none", + variants: { + isHoveredOnStop: { + true: { + cursor: "default", + }, + false: { + cursor: "copy", + }, + }, + }, +}); + +const SliderRange = styled(Range, { + position: "absolute", + background: "transparent", + borderRadius: theme.borderRadius[3], +}); + +const SliderThumb = styled(Thumb, { + display: "block", + transform: `translateY(calc(-1 * ${theme.spacing[9]} - 10px))`, + outline: `3px solid ${theme.colors.borderFocus}`, + borderRadius: theme.borderRadius[5], + outlineOffset: -3, + + "&::before": { + content: "''", + position: "absolute", + borderLeft: "5px solid transparent", + borderRight: "5px solid transparent", + borderTop: `5px solid ${theme.colors.borderFocus}`, + bottom: -5, + marginLeft: "50%", + transform: "translateX(-50%)", + }, +}); + +const SliderThumbTrigger = styled(Box, { + width: theme.spacing[10], + height: theme.spacing[10], +}); diff --git a/packages/css-data/src/property-parsers/index.ts b/packages/css-data/src/property-parsers/index.ts new file mode 100644 index 000000000000..a0961be1756c --- /dev/null +++ b/packages/css-data/src/property-parsers/index.ts @@ -0,0 +1 @@ +export * from "./linear-gradient"; diff --git a/packages/css-data/src/property-parsers/linear-gradient.ts b/packages/css-data/src/property-parsers/linear-gradient.ts index 7af4bcdc1cbe..0c6313ac9317 100644 --- a/packages/css-data/src/property-parsers/linear-gradient.ts +++ b/packages/css-data/src/property-parsers/linear-gradient.ts @@ -12,13 +12,13 @@ import namesPlugin from "colord/plugins/names"; extend([namesPlugin]); -interface GradientStop { +export interface GradientStop { color?: RgbValue; position?: UnitValue; hint?: UnitValue; } -interface ParsedGradient { +export interface ParsedGradient { angle?: UnitValue; sideOrCorner?: KeywordValue; stops: GradientStop[]; @@ -182,7 +182,7 @@ const getColor = ( }; export const reconstructLinearGradient = (parsed: ParsedGradient): string => { - const direction = parsed.angle || parsed.sideOrCorner; + const direction = parsed?.angle || parsed?.sideOrCorner; const stops = parsed.stops .map((stop: GradientStop) => { let result = toValue(stop.color); diff --git a/packages/icons/icons/chevron-filled-up.svg b/packages/icons/icons/chevron-filled-up.svg new file mode 100644 index 000000000000..90805644aff1 --- /dev/null +++ b/packages/icons/icons/chevron-filled-up.svg @@ -0,0 +1,9 @@ + + + diff --git a/packages/icons/icons/chevron-up.svg b/packages/icons/icons/chevron-up.svg index aef65ef27968..153ea1c2a31a 100644 --- a/packages/icons/icons/chevron-up.svg +++ b/packages/icons/icons/chevron-up.svg @@ -1,5 +1,14 @@ - - - - + + diff --git a/packages/icons/src/__generated__/components.tsx b/packages/icons/src/__generated__/components.tsx index 85139186056a..621a8727a5dd 100644 --- a/packages/icons/src/__generated__/components.tsx +++ b/packages/icons/src/__generated__/components.tsx @@ -1569,6 +1569,25 @@ export const ChevronDownIcon: IconComponent = forwardRef( ); ChevronDownIcon.displayName = "ChevronDownIcon"; +export const ChevronFilledUpIcon: IconComponent = forwardRef( + ({ fill = "none", size = 16, ...props }, forwardedRef) => { + return ( + + + + ); + } +); +ChevronFilledUpIcon.displayName = "ChevronFilledUpIcon"; + export const ChevronLeftIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( @@ -1632,12 +1651,10 @@ export const ChevronUpIcon: IconComponent = forwardRef( ref={forwardedRef} > ); diff --git a/packages/icons/src/__generated__/svg.ts b/packages/icons/src/__generated__/svg.ts index 506b399bb5f1..209d173107db 100644 --- a/packages/icons/src/__generated__/svg.ts +++ b/packages/icons/src/__generated__/svg.ts @@ -116,11 +116,13 @@ export const CheckboxCheckedIcon = ``; +export const ChevronFilledUpIcon = ``; + export const ChevronLeftIcon = ``; export const ChevronRightIcon = ``; -export const ChevronUpIcon = ``; +export const ChevronUpIcon = ``; export const ChevronsLeftIcon = ``; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59f42090f6e8..c0bff5525360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4360,6 +4360,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.2.2': + resolution: {integrity: sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA==} + peerDependencies: + '@types/react': ^18.2.70 + '@types/react-dom': ^18.2.25 + react: 18.3.0-canary-14898b6a9-20240318 + react-dom: 18.3.0-canary-14898b6a9-20240318 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.2': resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -11487,6 +11500,25 @@ snapshots: '@types/react': 18.2.79 '@types/react-dom': 18.2.25 + '@radix-ui/react-slider@1.2.2(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.1(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-direction': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + react: 18.3.0-canary-14898b6a9-20240318 + react-dom: 18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.2.25 + '@radix-ui/react-slot@1.0.2(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318)': dependencies: '@babel/runtime': 7.25.0