diff --git a/packages/css-data/src/index.ts b/packages/css-data/src/index.ts index 81f06b58103f..b11d0d27ce97 100644 --- a/packages/css-data/src/index.ts +++ b/packages/css-data/src/index.ts @@ -8,6 +8,7 @@ export { } from "./__generated__/property-value-descriptions"; export * from "./__generated__/animatable-properties"; export * from "./__generated__/pseudo-elements"; +export * from "./property-parsers"; // shorthand property parsers export * from "./parse-css-value"; 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..cc1af7aecd68 100644 --- a/packages/css-data/src/property-parsers/linear-gradient.ts +++ b/packages/css-data/src/property-parsers/linear-gradient.ts @@ -12,17 +12,17 @@ import namesPlugin from "colord/plugins/names"; extend([namesPlugin]); -interface GradientStop { +export type GradientStop = { color?: RgbValue; position?: UnitValue; hint?: UnitValue; -} +}; -interface ParsedGradient { +export type ParsedGradient = { angle?: UnitValue; sideOrCorner?: KeywordValue; stops: GradientStop[]; -} +}; const sideOrCorderIdentifiers = ["to", "top", "bottom", "left", "right"]; @@ -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/design-system/package.json b/packages/design-system/package.json index fce70513308f..5c4631df9bc3 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -58,7 +58,10 @@ "@react-aria/interactions": "^3.23.0", "@react-aria/utils": "^3.27.0", "@stitches/react": "1.3.1-1", + "@webstudio-is/css-data": "workspace:*", + "@webstudio-is/css-engine": "workspace:*", "@webstudio-is/icons": "workspace:*", + "colord": "^2.9.3", "change-case": "^5.4.4", "cmdk": "^1.1.1", "downshift": "^6.1.7", diff --git a/packages/design-system/src/components/gradient-picker.stories.tsx b/packages/design-system/src/components/gradient-picker.stories.tsx new file mode 100644 index 000000000000..445bf761aac2 --- /dev/null +++ b/packages/design-system/src/components/gradient-picker.stories.tsx @@ -0,0 +1,68 @@ +import { + parseLinearGradient, + reconstructLinearGradient, + type ParsedGradient, +} from "@webstudio-is/css-data"; +import { useState } from "react"; +import { GradientPicker } from "./gradient-picker"; +import { Flex } from "./flex"; +import { Text } from "./text"; + +export default { + title: "Library/GradientPicker", +}; + +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/packages/design-system/src/components/gradient-picker.tsx b/packages/design-system/src/components/gradient-picker.tsx new file mode 100644 index 000000000000..456b059d6788 --- /dev/null +++ b/packages/design-system/src/components/gradient-picker.tsx @@ -0,0 +1,490 @@ +import { clamp } from "@react-aria/utils"; +import { toValue, UnitValue, type RgbValue } from "@webstudio-is/css-engine"; +import { + useState, + useCallback, + useRef, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + reconstructLinearGradient, + type GradientStop, + type ParsedGradient, +} from "@webstudio-is/css-data"; +import { colord, extend } from "colord"; +import mixPlugin from "colord/plugins/mix"; +import { ChevronFilledUpIcon } from "@webstudio-is/icons"; +import { styled, theme } from "../stitches.config"; +import { Flex } from "./flex"; +import { Box } from "./box"; + +extend([mixPlugin]); + +type GradientPickerProps = { + gradient: ParsedGradient; + onChange: (value: ParsedGradient) => void; + onThumbSelected: (index: number, stop: GradientStop) => void; +}; + +const defaultAngle: UnitValue = { + type: "unit", + value: 90, + unit: "deg", +}; + +const THUMB_INTERACTION_PX = 12; + +export const GradientPicker = (props: GradientPickerProps) => { + const { gradient, onChange, onThumbSelected } = props; + const [stops, setStops] = useState>(gradient.stops); + const [selectedStop, setSelectedStop] = useState(); + const [isHoveredOnStop, setIsHoveredOnStop] = useState(false); + const sliderRef = useRef(null); + + const positions = stops + .map((stop) => stop.position?.value) + .filter((item): item is number => item !== undefined); + const hints = gradient.stops + .map((stop): number | undefined => stop.hint?.value) + .filter((item): item is number => item !== undefined); + const background = reconstructLinearGradient({ + stops, + sideOrCorner: gradient.sideOrCorner, + angle: defaultAngle, + }); + + const updateStops = useCallback( + (updater: (currentStops: GradientStop[]) => GradientStop[]) => { + setStops((currentStops) => { + const nextStops = updater(currentStops); + onChange({ + angle: gradient.angle, + stops: nextStops, + sideOrCorner: gradient.sideOrCorner, + }); + return nextStops; + }); + }, + [gradient.angle, gradient.sideOrCorner, onChange] + ); + + const updateStopPosition = useCallback( + (index: number, value: number) => { + const nextValue = clamp(value, 0, 100); + updateStops((currentStops) => { + if (index < 0 || index >= currentStops.length) { + return currentStops; + } + + return currentStops.map((stop, stopIndex) => { + if (stopIndex !== index) { + return stop; + } + + const nextPosition = { + type: "unit", + unit: "%", + value: nextValue, + } as const; + + if (stop.position === undefined) { + return { + ...stop, + position: nextPosition, + }; + } + + return { + ...stop, + position: nextPosition, + }; + }); + }); + }, + [updateStops] + ); + + const computePositionFromClientX = useCallback((clientX: number) => { + const rect = sliderRef.current?.getBoundingClientRect(); + if (rect === undefined || rect.width === 0) { + return 0; + } + const relativePosition = clientX - rect.left; + return clamp(Math.round((relativePosition / rect.width) * 100), 0, 100); + }, []); + + const checkIfStopExistsAtPosition = useCallback( + ( + clientX: number + ): { + isStopExistingAtPosition: boolean; + newPosition: number; + } => { + const rect = sliderRef.current?.getBoundingClientRect(); + const newPosition = computePositionFromClientX(clientX); + + if (rect === undefined || rect.width === 0) { + return { isStopExistingAtPosition: false, newPosition }; + } + + const relativeX = clamp(clientX - rect.left, 0, rect.width); + const isStopExistingAtPosition = positions.some((position) => { + const positionPx = (position / 100) * rect.width; + return Math.abs(positionPx - relativeX) <= THUMB_INTERACTION_PX; + }); + + return { isStopExistingAtPosition, newPosition }; + }, + [computePositionFromClientX, positions] + ); + + const handleStopSelected = useCallback( + (index: number, stop: GradientStop) => { + setSelectedStop(index); + onThumbSelected(index, stop); + }, + [onThumbSelected] + ); + + const handleThumbPointerDown = useCallback( + (index: number, stop: GradientStop) => + (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + + handleStopSelected(index, stop); + setIsHoveredOnStop(true); + + const pointerId = event.pointerId; + const target = event.currentTarget; + target.setPointerCapture(pointerId); + + const handlePointerMove = (moveEvent: PointerEvent) => { + const newPosition = computePositionFromClientX(moveEvent.clientX); + updateStopPosition(index, newPosition); + }; + + const handlePointerUp = () => { + target.releasePointerCapture(pointerId); + target.removeEventListener("pointermove", handlePointerMove); + target.removeEventListener("pointerup", handlePointerUp); + target.removeEventListener("pointercancel", handlePointerUp); + setIsHoveredOnStop(false); + }; + + target.addEventListener("pointermove", handlePointerMove); + target.addEventListener("pointerup", handlePointerUp); + target.addEventListener("pointercancel", handlePointerUp); + }, + [computePositionFromClientX, handleStopSelected, updateStopPosition] + ); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (selectedStop === undefined) { + return; + } + + if (event.key === "Backspace") { + event.preventDefault(); + let nextSelection: + | { index: number | undefined; stop?: GradientStop } + | undefined; + + updateStops((currentStops) => { + if (selectedStop < 0 || selectedStop >= currentStops.length) { + return currentStops; + } + + const nextStops = currentStops.filter( + (_, index) => index !== selectedStop + ); + + if (nextStops.length > 0) { + const candidateIndex = Math.min(selectedStop, nextStops.length - 1); + nextSelection = { + index: candidateIndex, + stop: nextStops[candidateIndex], + }; + } else { + nextSelection = { index: undefined }; + } + + return nextStops; + }); + + if (nextSelection?.index !== undefined && nextSelection.stop) { + setSelectedStop(nextSelection.index); + onThumbSelected(nextSelection.index, nextSelection.stop); + } else { + setSelectedStop(undefined); + } + + return; + } + + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + const delta = event.key === "ArrowLeft" ? -step : step; + const currentPosition = stops[selectedStop]?.position?.value ?? 0; + updateStopPosition(selectedStop, currentPosition + delta); + } + }, + [onThumbSelected, selectedStop, stops, updateStopPosition, updateStops] + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-thumb='true']") + ) { + return; + } + + const { isStopExistingAtPosition, newPosition } = + checkIfStopExistsAtPosition(event.clientX); + + if (isStopExistingAtPosition === true) { + return; + } + + event.preventDefault(); + + let nextSelection: { index: number; stop: GradientStop } | undefined; + + updateStops((currentStops) => { + if (currentStops.length === 0) { + return currentStops; + } + + const currentPositions = currentStops + .map((stop) => stop.position?.value) + .filter((value): value is number => value !== undefined); + + const newStopIndex = currentPositions.findIndex( + (position) => position > newPosition + ); + const insertionIndex = + newStopIndex === -1 ? currentStops.length : newStopIndex; + + const prevIndex = insertionIndex === 0 ? 0 : insertionIndex - 1; + const nextIndex = + insertionIndex === currentStops.length + ? currentStops.length - 1 + : insertionIndex; + + const prevColor = currentStops[prevIndex]?.color; + const nextColor = currentStops[nextIndex]?.color ?? prevColor; + + if (prevColor === undefined && nextColor === undefined) { + return currentStops; + } + + const interpolationColor = + prevColor !== undefined && nextColor !== undefined + ? colord(toValue(prevColor)) + .mix(colord(toValue(nextColor)), newPosition / 100) + .toRgb() + : colord(toValue((prevColor ?? nextColor)!)).toRgb(); + + const newColorStop: RgbValue = { + type: "rgb", + alpha: interpolationColor.a, + r: interpolationColor.r, + g: interpolationColor.g, + b: interpolationColor.b, + }; + + const newStop: GradientStop = { + color: newColorStop, + position: { type: "unit", value: newPosition, unit: "%" }, + }; + + const nextStops: GradientStop[] = [ + ...currentStops.slice(0, insertionIndex), + newStop, + ...currentStops.slice(insertionIndex), + ]; + + nextSelection = { index: insertionIndex, stop: newStop }; + + return nextStops; + }); + + if (nextSelection !== undefined) { + setSelectedStop(nextSelection.index); + onThumbSelected(nextSelection.index, nextSelection.stop); + } + + setIsHoveredOnStop(true); + }, + [checkIfStopExistsAtPosition, onThumbSelected, updateStops] + ); + + const handleMouseIndicator = useCallback( + (event: ReactMouseEvent) => { + const { isStopExistingAtPosition } = checkIfStopExistsAtPosition( + event.clientX + ); + setIsHoveredOnStop(isStopExistingAtPosition); + }, + [checkIfStopExistsAtPosition] + ); + + const handleSliderFocus = useCallback(() => { + if (selectedStop !== undefined || stops.length === 0) { + return; + } + + const [firstStop] = stops; + if (firstStop !== undefined) { + handleStopSelected(0, firstStop); + } + }, [handleStopSelected, selectedStop, stops]); + + if ( + stops.some( + (stop) => stop.position === undefined || stop.color === undefined + ) + ) { + return null; + } + + return ( + + setIsHoveredOnStop(false)} + > + + {stops.map((stop, index) => { + if (stop.color === undefined || stop.position === undefined) { + return null; + } + + return ( + handleStopSelected(index, stop)} + > + + + ); + })} + + {hints.map((hint) => { + return ( + + + + ); + })} + + + ); +}; + +const SliderRoot = styled("div", { + position: "relative", + width: "100%", + height: theme.spacing[9], + border: `1px solid ${theme.colors.borderMain}`, + borderRadius: theme.borderRadius[3], + touchAction: "none", + userSelect: "none", + outline: "none", + variants: { + isHoveredOnStop: { + true: { + cursor: "default", + }, + false: { + cursor: "copy", + }, + }, + }, + "&:focus-visible": { + boxShadow: `0 0 0 2px ${theme.colors.borderFocus}`, + }, +}); + +const SliderTrack = styled("div", { + position: "absolute", + inset: 0, + borderRadius: theme.borderRadius[3], + pointerEvents: "none", +}); + +const SliderThumb = styled(Box, { + position: "absolute", + display: "block", + transform: `translate(-50%, calc(-1 * ${theme.spacing[9]} - 10px))`, + outline: `3px solid ${theme.colors.borderFocus}`, + borderRadius: theme.borderRadius[5], + outlineOffset: -3, + zIndex: 1, + cursor: "grab", + "&:active": { + cursor: "grabbing", + }, + "&::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], + borderRadius: theme.borderRadius[4], + backgroundColor: "inherit", +}); + +export type { GradientPickerProps }; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index 45056946c7f9..fa609fc9a240 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -48,6 +48,7 @@ export * from "./components/panel-banner"; export * from "./components/focus-ring"; export * from "./components/tree"; export * from "./components/command"; +export * from "./components/gradient-picker"; // Not aligned 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 7bf0a77a4576..48a1c059a4ac 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 de5a3ae07b5e..298d83fc67c1 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 ba2a74f2bc6a..d346e86ff188 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1426,6 +1426,12 @@ importers: '@stitches/react': specifier: 1.3.1-1 version: 1.3.1-1(patch_hash=knml42mpr6mlzseo3d6gjljiq4)(react@18.3.0-canary-14898b6a9-20240318) + '@webstudio-is/css-data': + specifier: workspace:* + version: link:../css-data + '@webstudio-is/css-engine': + specifier: workspace:* + version: link:../css-engine '@webstudio-is/icons': specifier: workspace:* version: link:../icons @@ -1435,6 +1441,9 @@ importers: cmdk: specifier: ^1.1.1 version: 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) + colord: + specifier: ^2.9.3 + version: 2.9.3 downshift: specifier: ^6.1.7 version: 6.1.7(react@18.3.0-canary-14898b6a9-20240318)