From fa95f39921c4274b2e0e42037aec54ad6c507790 Mon Sep 17 00:00:00 2001 From: Constant Gillet Date: Mon, 17 Jun 2024 23:53:42 +0200 Subject: [PATCH] Add shadow (#10) * WIP add border shadow properties * Add shadow properties in panel * Add render of shadow * Add opacity in Rect Element shadow * add states in shadow properties --------- Co-authored-by: constant --- app/components/PropertiesPanel.tsx | 21 + app/components/RectElement.tsx | 30 +- app/components/ShadowProperties.tsx | 480 ++++++++++++++++++ app/render-components/RectElementRendered.tsx | 30 +- app/stores/elementTypes.tsx | 278 +++++----- 5 files changed, 700 insertions(+), 139 deletions(-) create mode 100644 app/components/ShadowProperties.tsx diff --git a/app/components/PropertiesPanel.tsx b/app/components/PropertiesPanel.tsx index e1546b9..be959b8 100644 --- a/app/components/PropertiesPanel.tsx +++ b/app/components/PropertiesPanel.tsx @@ -18,6 +18,7 @@ import { BorderProperties } from "./BorderProperties"; import { Icon } from "./Icon"; import { BlurProperties } from "./BlurProperties"; import { TextShadowProperties } from "./TextShadowProperties"; +import { ShadowProperties } from "./ShadowProperties"; const Separator = () => { return ( @@ -258,6 +259,26 @@ export const PropertiesPanel = () => { )} + {properties?.shadowXOffset !== undefined && + properties?.shadowYOffset !== undefined && + properties?.shadowColor !== undefined && + properties?.shadowBlur !== undefined && + properties?.shadowSpread !== undefined && + properties?.shadowOpacity !== undefined && ( + <> + + + + )} { // If empty properties !Object.keys(properties).length && ( diff --git a/app/components/RectElement.tsx b/app/components/RectElement.tsx index a73fc07..28b3b7b 100644 --- a/app/components/RectElement.tsx +++ b/app/components/RectElement.tsx @@ -6,6 +6,29 @@ import type { RectElement as RectElementType } from "~/stores/elementTypes"; type ReactElementProps = RectElementType; export const RectElement = (props: ReactElementProps) => { + //Create boxShadow string with border and shadow properties + const shadowValue = + props.shadowXOffset && + props.shadowYOffset && + props.shadowBlur && + props.shadowSpread && + props.shadowColor + ? `${props.shadowXOffset}px ${props.shadowYOffset}px ${ + props.shadowBlur + }px ${props.shadowSpread}px ${addAlphaToHex( + props.shadowColor, + props.shadowOpacity || 1, + )}` + : undefined; + const borderValue = + props.borderColor && props.borderType && props.borderWidth + ? `0px 0px 0px ${props.borderWidth}px ${ + props.borderType === "inside" ? "inset" : "" + } ${props.borderColor}` + : undefined; + + const shadowList = [shadowValue, borderValue].filter(Boolean).join(", "); + return (
{ borderTopRightRadius: `${props.borderTopRightRadius}px`, borderBottomLeftRadius: `${props.borderBottomLeftRadius}px`, borderBottomRightRadius: `${props.borderBottomRightRadius}px`, - boxShadow: - props.borderColor && props.borderType && props.borderWidth - ? `0px 0px 0px ${props.borderWidth}px ${ - props.borderType === "inside" ? "inset" : "" - } ${props.borderColor}` - : "none", + boxShadow: shadowList !== "" ? shadowList : "unset", filter: props.blur ? `blur(${props.blur}px)` : "none", }} /> diff --git a/app/components/ShadowProperties.tsx b/app/components/ShadowProperties.tsx new file mode 100644 index 0000000..aa1399c --- /dev/null +++ b/app/components/ShadowProperties.tsx @@ -0,0 +1,480 @@ +import { Box, Flex, Grid, Popover, TextField } from "@radix-ui/themes"; +import { PanelGroup, type ValueType } from "./PropertiesPanel"; +import { Icon } from "./Icon"; +import { type ChangeEvent, useEffect, useMemo, useState } from "react"; +import { arePropertiesTheSame } from "~/utils/arePropertiesTheSame"; +import { useEditorStore } from "../stores/EditorStore"; +import { getVarFromString } from "~/utils/getVarFromString"; +import { getElementVariables } from "~/stores/actions/getElementVariables"; +import { PropertyTextField } from "./PropertyTextField"; +import { groupBySameColor } from "~/utils/groupBySameColor"; +import { css } from "styled-system/css"; +import { Button, Select } from "@radix-ui/themes"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import * as PopoverRadix from "@radix-ui/react-popover"; +import { updateElementsVariables } from "~/stores/actions/updateElementsVariables"; +import { getVariablesWithoutProperty } from "~/utils/getVariablesWithoutProperty"; +import * as SelectPicker from "react-color"; +import { grid, gridItem } from "styled-system/patterns"; + +type ShadowPropertiesProps = { + properties: { + shadowXOffset: ValueType[]; + shadowYOffset: ValueType[]; + shadowBlur: ValueType[]; + shadowColor: ValueType[]; + shadowOpacity: ValueType[]; + shadowSpread: ValueType[]; + }; +}; + +//Check if properties values are different from null +const propertiesHaveValues = (properties: ValueType[]) => { + return properties.some((property) => property.value !== null); +}; +export const ShadowProperties = (props: ShadowPropertiesProps) => { + const updateElements = useEditorStore((state) => state.updateElements); + const hasValues = + propertiesHaveValues(props.properties.shadowXOffset) && + propertiesHaveValues(props.properties.shadowYOffset) && + propertiesHaveValues(props.properties.shadowBlur) && + propertiesHaveValues(props.properties.shadowColor) && + propertiesHaveValues(props.properties.shadowOpacity) && + propertiesHaveValues(props.properties.shadowSpread); + + const [colorValues, setColorValues] = useState( + groupBySameColor( + props.properties.shadowColor, + props.properties.shadowOpacity, + ), + ); + + useEffect(() => { + setColorValues( + groupBySameColor( + props.properties.shadowColor, + props.properties.shadowOpacity, + ), + ); + }, [props.properties.shadowColor, props.properties.shadowOpacity]); + + const setDefaultValueFromProps = ( + property: keyof ShadowPropertiesProps["properties"], + ) => { + return arePropertiesTheSame(props.properties[property]) && + !props.properties[property][0].variable + ? props.properties[property][0].value + : arePropertiesTheSame(props.properties[property]) + ? `{{${props.properties[property][0].variableName}}}` + : "Mixed"; + }; + + const [shadowXOffsetValue, setShadowXOffset] = useState( + setDefaultValueFromProps("shadowXOffset"), + ); + + useEffect(() => { + setShadowXOffset(setDefaultValueFromProps("shadowXOffset")); + }, [props.properties.shadowXOffset]); + + const [shadowYOffsetValue, setShadowYOffset] = useState( + setDefaultValueFromProps("shadowYOffset"), + ); + + useEffect(() => { + setShadowYOffset(setDefaultValueFromProps("shadowYOffset")); + }, [props.properties.shadowYOffset]); + + const [shadowBlurValue, setShadowBlur] = useState( + setDefaultValueFromProps("shadowBlur"), + ); + + useEffect(() => { + setShadowBlur(setDefaultValueFromProps("shadowBlur")); + }, [props.properties.shadowBlur]); + + const [shadowSpreadValue, setShadowSpread] = useState( + setDefaultValueFromProps("shadowSpread"), + ); + + useEffect(() => { + setShadowSpread(setDefaultValueFromProps("shadowSpread")); + }, [props.properties.shadowSpread]); + + const applyProperty = ( + event: React.ChangeEvent, + property: keyof ShadowPropertiesProps["properties"], + ) => { + const newValue = event.target.value; + + const variableName = getVarFromString(newValue); + + if (variableName && variableName.length > 0) { + updateElements( + props.properties[property].map((property) => { + const currentVariables = getElementVariables(property.nodeId); + + //Create new vaiables with the new variable if it doesn't exist + const newVariablesWithoutProperty = currentVariables.filter( + (variable) => variable.property !== property.propertyName, + ); + + const newVariables = [ + ...newVariablesWithoutProperty, + { + property: property.propertyName, + name: variableName, + }, + ]; + + return { + id: property.nodeId, + variables: newVariables, + }; + }), + true, + ); + + return; + } + + if (isNaN(Number(newValue))) { + return; + } + + const value = Number(newValue); + + updateElements( + props.properties[property].map((property) => { + const newVariablesWithoutProperty = getVariablesWithoutProperty( + property.propertyName, + property.nodeId, + ); + + return { + id: property.nodeId, + [property.propertyName]: value, + variables: newVariablesWithoutProperty, + }; + }), + true, + ); + + event.target.blur(); + }; + + const onKeyUp = ( + e: React.KeyboardEvent, + property: keyof ShadowPropertiesProps["properties"], + ) => { + if (e.key === "Enter") { + applyProperty(e, property); + } + }; + + const addDefault = () => { + updateElements( + props.properties.shadowXOffset.map((property) => { + return { + id: property.nodeId, + shadowXOffset: 1, + shadowYOffset: 1, + shadowBlur: 1, + shadowColor: "#000000", + shadowOpacity: 1, + shadowSpread: 1, + }; + }), + true, + ); + }; + + const removeDefault = () => { + updateElements( + props.properties.shadowXOffset.map((property) => { + return { + id: property.nodeId, + shadowXOffset: null, + shadowYOffset: null, + shadowBlur: null, + shadowColor: null, + shadowOpacity: null, + shadowSpread: null, + }; + }), + true, + ); + }; + + return ( + + {hasValues ? ( + <> + + + setShadowXOffset(e.target.value)} + onBlur={(e) => applyProperty(e, "shadowXOffset")} + onKeyUp={(e) => onKeyUp(e, "shadowXOffset")} + /> + + + setShadowYOffset(e.target.value)} + onBlur={(e) => applyProperty(e, "shadowYOffset")} + onKeyUp={(e) => onKeyUp(e, "shadowYOffset")} + /> + + + + + } + placeholder="Blur" + hasVariable={props.properties.shadowBlur[0].variable || false} + value={shadowBlurValue} + onChange={(e) => setShadowBlur(e.target.value)} + onBlur={(e) => applyProperty(e, "shadowBlur")} + onKeyUp={(e) => onKeyUp(e, "shadowBlur")} + /> + + + setShadowSpread(e.target.value)} + onBlur={(e) => applyProperty(e, "shadowSpread")} + onKeyUp={(e) => onKeyUp(e, "shadowSpread")} + /> + + + {colorValues.map((color) => ( + + ))} + + ) : null} + + ); +}; + +type ColorLineProps = { + color: { + elementIds: string[]; + value: string; + opacity: number; + colorVariable?: string | undefined; + opacityVariable?: string | undefined; + }; +}; + +const ColorLine = ({ color }: ColorLineProps) => { + const updateElements = useEditorStore((state) => state.updateElements); + + const [opacity, setOpacity] = useState( + color.opacityVariable + ? `{{${color.opacityVariable}}}` + : `${color.opacity * 100}%`, + ); + + const [colorValue, setColorValue] = useState( + color?.colorVariable ? `{{${color.colorVariable}}}` : color.value, + ); + + useEffect(() => { + setColorValue( + color?.colorVariable ? `{{${color.colorVariable}}}` : color.value, + ); + }, [color.value, color.colorVariable]); + + const applyColor = (newColor: string, saveToHistory = false) => { + const elementIds = color.elementIds; + + updateElements( + elementIds.map((elementId) => ({ + id: elementId, + shadowColor: newColor, + })), + saveToHistory, + ); + }; + + const applyColorInput = (newColorValue: string) => { + const elementIds = color.elementIds; + + const variableName = getVarFromString(newColorValue); + + if (variableName && variableName.length > 0) { + updateElementsVariables(elementIds, "shadowColor", variableName); + return; + } + + //Check if the color is hex color and valid + if (!/^#[0-9A-F]{6}$/i.test(newColorValue)) { + return; + } + + updateElements( + elementIds.map((elementId) => { + const newVariablesWithoutProperty = getVariablesWithoutProperty( + "shadowColor", + elementId, + ); + + return { + id: elementId, + shadowColor: newColorValue, + variables: newVariablesWithoutProperty, + }; + }), + true, + ); + }; + + const applyOpacity = (opacity: string) => { + const elementIds = color.elementIds; + + const variableName = getVarFromString(opacity); + + if (variableName && variableName.length > 0) { + updateElementsVariables(elementIds, "shadowOpacity", variableName); + return; + } + + const opacityValue = Number(opacity.replace("%", "")) / 100; + + if (isNaN(opacityValue) || opacityValue < 0 || opacityValue > 1) { + return; + } + + updateElements( + elementIds.map((elementId) => { + const newVariablesWithoutProperty = getVariablesWithoutProperty( + "shadowOpacity", + elementId, + ); + + return { + id: elementId, + shadowOpacity: opacityValue, + variables: newVariablesWithoutProperty, + }; + }), + true, + ); + }; + + return ( + + <> + +
+
+ e.stopPropagation()}> +
+
+ { + setOpacity(e.target.value); + }} + onBlur={(e) => { + applyOpacity(e.target.value); + }} + onKeyUp={(e) => { + if (e.key === "Enter") { + applyOpacity(e.currentTarget.value); + } + }} + /> +
+
+
+ + { + applyColor(newColor.hex); + }} + onChangeComplete={(newColor) => { + applyColor(newColor.hex, true); + }} + /> + + +
+ ); +}; diff --git a/app/render-components/RectElementRendered.tsx b/app/render-components/RectElementRendered.tsx index 8507129..3cba371 100644 --- a/app/render-components/RectElementRendered.tsx +++ b/app/render-components/RectElementRendered.tsx @@ -4,6 +4,29 @@ import { addAlphaToHex } from "~/utils/addAlphaToHex"; type RectElementProps = RectElement; export const RectElementRendered = (props: RectElementProps) => { + //Create boxShadow string with border and shadow properties + const shadowValue = + props.shadowXOffset && + props.shadowYOffset && + props.shadowBlur && + props.shadowSpread && + props.shadowColor + ? `${props.shadowXOffset}px ${props.shadowYOffset}px ${ + props.shadowBlur + }px ${props.shadowSpread}px ${addAlphaToHex( + props.shadowColor, + props.shadowOpacity || 1, + )}` + : undefined; + const borderValue = + props.borderColor && props.borderType && props.borderWidth + ? `0px 0px 0px ${props.borderWidth}px ${ + props.borderType === "inside" ? "inset" : "" + } ${props.borderColor}` + : undefined; + + const shadowList = [shadowValue, borderValue].filter(Boolean).join(", "); + return (
{ borderTopRightRadius: `${props.borderTopRightRadius}px`, borderBottomLeftRadius: `${props.borderBottomLeftRadius}px`, borderBottomRightRadius: `${props.borderBottomRightRadius}px`, - boxShadow: - props.borderColor && props.borderType && props.borderWidth - ? `0px 0px 0px ${props.borderWidth}px ${ - props.borderType === "inside" ? "inset" : "" - } ${props.borderColor}` - : "none", + boxShadow: shadowList !== "" ? shadowList : "unset", filter: props.blur ? `blur(${props.blur}px)` : "none", }} /> diff --git a/app/stores/elementTypes.tsx b/app/stores/elementTypes.tsx index 0764710..343dc9a 100644 --- a/app/stores/elementTypes.tsx +++ b/app/stores/elementTypes.tsx @@ -6,187 +6,211 @@ export type ElementType = z.infer; //Base element properties that all elements share const BaseElementSchema = z.object({ - id: z.string(), - type: ElementType, - x: z.number(), - y: z.number(), - width: z.number(), - height: z.number(), - rotate: z.number(), + id: z.string(), + type: ElementType, + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + rotate: z.number(), }); export const RectElementSchema = BaseElementSchema.merge( - z.object({ - type: z.enum(["rect"]), - backgroundColor: z.string(), - backgroundOpacity: z.number(), - borderTopLeftRadius: z.number(), - borderTopRightRadius: z.number(), - borderBottomLeftRadius: z.number(), - borderBottomRightRadius: z.number(), - borderColor: z.string().nullish().default(null), - borderWidth: z.number().nullish().default(null), - borderType: z.enum(["inside", "outside"]).nullish().default(null), - blur: z.number().nullish().default(null), - }), + z.object({ + type: z.enum(["rect"]), + backgroundColor: z.string(), + backgroundOpacity: z.number(), + borderTopLeftRadius: z.number(), + borderTopRightRadius: z.number(), + borderBottomLeftRadius: z.number(), + borderBottomRightRadius: z.number(), + borderColor: z.string().nullish().default(null), + borderWidth: z.number().nullish().default(null), + borderType: z.enum(["inside", "outside"]).nullish().default(null), + blur: z.number().nullish().default(null), + shadowXOffset: z.number().nullish().default(null), + shadowYOffset: z.number().nullish().default(null), + shadowBlur: z.number().nullish().default(null), + shadowColor: z.string().nullish().default(null), + shadowOpacity: z.number().nullish().default(null), + shadowSpread: z.number().nullish().default(null), + }) ); export type RectElement = z.infer; //Text element properties export const TextElementSchema = BaseElementSchema.merge( - z.object({ - type: z.literal("text"), - content: z.string(), - fontSize: z.number(), - color: z.string(), - textColorOpacity: z.number(), - fontFamily: z.string(), - fontWeight: z.number(), - fontStyle: z.enum(["normal", "italic"]), - textAlign: z.enum(["left", "center", "right", "justify"]), - textTransform: z.enum(["none", "uppercase", "lowercase", "capitalize"]), - lineHeight: z.number(), - blur: z.number().nullish().default(null), - textShadowXOffset: z.number().nullish().default(null), - textShadowYOffset: z.number().nullish().default(null), - textShadowBlur: z.number().nullish().default(null), - textShadowColor: z.string().nullish().default(null), - }), + z.object({ + type: z.literal("text"), + content: z.string(), + fontSize: z.number(), + color: z.string(), + textColorOpacity: z.number(), + fontFamily: z.string(), + fontWeight: z.number(), + fontStyle: z.enum(["normal", "italic"]), + textAlign: z.enum(["left", "center", "right", "justify"]), + textTransform: z.enum(["none", "uppercase", "lowercase", "capitalize"]), + lineHeight: z.number(), + blur: z.number().nullish().default(null), + textShadowXOffset: z.number().nullish().default(null), + textShadowYOffset: z.number().nullish().default(null), + textShadowBlur: z.number().nullish().default(null), + textShadowColor: z.string().nullish().default(null), + }) ); export type TextElement = z.infer; //Image element properties export const ImageElementSchema = BaseElementSchema.merge( - z.object({ - type: z.literal("image"), - src: z.string().nullable(), - objectFit: z.enum(["fill", "contain", "cover", "none", "scale-down"]), - borderTopLeftRadius: z.number(), - borderTopRightRadius: z.number(), - borderBottomLeftRadius: z.number(), - borderBottomRightRadius: z.number(), - borderColor: z.string().nullish().default(null), - borderWidth: z.number().nullish().default(null), - borderType: z.enum(["inside", "outside"]).nullish().default(null), - blur: z.number().nullish().default(null), - }), + z.object({ + type: z.literal("image"), + src: z.string().nullable(), + objectFit: z.enum(["fill", "contain", "cover", "none", "scale-down"]), + borderTopLeftRadius: z.number(), + borderTopRightRadius: z.number(), + borderBottomLeftRadius: z.number(), + borderBottomRightRadius: z.number(), + borderColor: z.string().nullish().default(null), + borderWidth: z.number().nullish().default(null), + borderType: z.enum(["inside", "outside"]).nullish().default(null), + blur: z.number().nullish().default(null), + shadowXOffset: z.number().nullish().default(null), + shadowYOffset: z.number().nullish().default(null), + shadowBlur: z.number().nullish().default(null), + shadowColor: z.string().nullish().default(null), + shadowOpacity: z.number().nullish().default(null), + shadowSpread: z.number().nullish().default(null), + }) ); export type ImageElement = z.infer; export const PageElementSchema = BaseElementSchema.merge( - z.object({ - type: z.literal("page"), - backgroundColor: z.string(), - backgroundOpacity: z.number(), - }), + z.object({ + type: z.literal("page"), + backgroundColor: z.string(), + backgroundOpacity: z.number(), + }) ); export type PageElement = z.infer; export const Variables = z.object({ - property: z.string(), - name: z.string(), + property: z.string(), + name: z.string(), }); export type Variables = z.infer; export const RectElementWithVariables = RectElementSchema.merge( - z.object({ - variables: z.array(Variables), - }), + z.object({ + variables: z.array(Variables), + }) ); export type RectElementWithVariables = z.infer; export const TextElementWithVariables = TextElementSchema.merge( - z.object({ - variables: z.array(Variables), - }), + z.object({ + variables: z.array(Variables), + }) ); export type TextElementWithVariables = z.infer; export const ImageElementWithVariables = ImageElementSchema.merge( - z.object({ - variables: z.array(Variables), - }), + z.object({ + variables: z.array(Variables), + }) ); export type ImageElementWithVariables = z.infer< - typeof ImageElementWithVariables + typeof ImageElementWithVariables >; export const defaultRectElement: RectElement = { - id: "", - type: "rect", - x: 0, - y: 0, - width: 100, - height: 100, - rotate: 0, - backgroundColor: "#ff0000", - backgroundOpacity: 1, - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - borderColor: null, - borderWidth: null, - borderType: null, - blur: null, + id: "", + type: "rect", + x: 0, + y: 0, + width: 100, + height: 100, + rotate: 0, + backgroundColor: "#ff0000", + backgroundOpacity: 1, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderColor: null, + borderWidth: null, + borderType: null, + blur: null, + shadowXOffset: null, + shadowYOffset: null, + shadowBlur: null, + shadowColor: null, + shadowOpacity: null, + shadowSpread: null, }; export const defaultTextElement: TextElement = { - id: "", - type: "text", - x: 0, - y: 0, - rotate: 0, - width: 100, - height: 100, - color: "#000000", - textColorOpacity: 1, - textTransform: "none", - fontSize: 28, - fontWeight: 400, - fontStyle: "normal", - fontFamily: "Open Sans", - textAlign: "left", - content: "Text", - lineHeight: 1.5, - blur: null, - textShadowXOffset: null, - textShadowYOffset: null, - textShadowBlur: null, - textShadowColor: null, + id: "", + type: "text", + x: 0, + y: 0, + rotate: 0, + width: 100, + height: 100, + color: "#000000", + textColorOpacity: 1, + textTransform: "none", + fontSize: 28, + fontWeight: 400, + fontStyle: "normal", + fontFamily: "Open Sans", + textAlign: "left", + content: "Text", + lineHeight: 1.5, + blur: null, + textShadowXOffset: null, + textShadowYOffset: null, + textShadowBlur: null, + textShadowColor: null, }; export const defaultImageElement: ImageElement = { - id: "", - type: "image", - x: 0, - y: 0, - rotate: 0, - width: 100, - height: 100, - src: null, - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - borderBottomLeftRadius: 0, - borderColor: null, - borderWidth: null, - borderType: null, - objectFit: "cover", - blur: null, + id: "", + type: "image", + x: 0, + y: 0, + rotate: 0, + width: 100, + height: 100, + src: null, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + borderBottomLeftRadius: 0, + borderColor: null, + borderWidth: null, + borderType: null, + objectFit: "cover", + blur: null, + shadowXOffset: null, + shadowYOffset: null, + shadowBlur: null, + shadowColor: null, + shadowOpacity: null, + shadowSpread: null, }; export const defaultElements = { - rect: defaultRectElement, - text: defaultTextElement, - image: defaultImageElement, + rect: defaultRectElement, + text: defaultTextElement, + image: defaultImageElement, }; //Element is type of RectElement, TextElement or ImageElement