From 08b3d71e9ed2dc3d1272b6e6b393da53395270b9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 12 Mar 2025 23:32:41 +0100 Subject: [PATCH] update: new color picker --- .../src/components/ColorPicker/ColorMenu.tsx | 324 +++++++++++ .../components/ColorPicker/ColorPicker.tsx | 536 +++++++++++++----- .../src/components/Toolbar/Toolbar.tsx | 21 +- webapp/IronCalc/src/locale/en_us.json | 7 +- 4 files changed, 752 insertions(+), 136 deletions(-) create mode 100644 webapp/IronCalc/src/components/ColorPicker/ColorMenu.tsx diff --git a/webapp/IronCalc/src/components/ColorPicker/ColorMenu.tsx b/webapp/IronCalc/src/components/ColorPicker/ColorMenu.tsx new file mode 100644 index 0000000..2c778ed --- /dev/null +++ b/webapp/IronCalc/src/components/ColorPicker/ColorMenu.tsx @@ -0,0 +1,324 @@ +import styled from "@emotion/styled"; +import { Menu, MenuItem } from "@mui/material"; +import { Plus } from "lucide-react"; +import { type JSX, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { theme } from "../../theme"; +import ColorPicker from "./ColorPicker"; + +type ColorMenuProps = { + onColorSelect: (color: string | null) => void; + anchorEl: React.RefObject; + open: boolean; + onClose: () => void; + renderMenuItem: ( + color: string, + handleColorSelect: (color: string | null) => void, + ) => JSX.Element; +}; + +const ColorMenu = ({ + onColorSelect, + anchorEl, + open, + onClose, + renderMenuItem, +}: ColorMenuProps) => { + const [color, setColor] = useState(theme.palette.common.black); + const recentColors = useRef([]); + const [isPickerOpen, setPickerOpen] = useState(false); + const { t } = useTranslation(); + + const handleColorSelect = (color: string | null) => { + if (color && !recentColors.current.includes(color)) { + recentColors.current.unshift(color); + } + onColorSelect(color); + setPickerOpen(false); + }; + + const mainColors = [ + "#FFFFFF", + "#272525", + "#1B717E", + "#3BB68A", + "#8CB354", + "#F8CD3C", + "#F2994A", + "#EC5753", + "#523E93", + "#3358B7", + ]; + + const lightTones = [ + theme.palette.grey[50], + theme.palette.grey[100], + theme.palette.grey[200], + theme.palette.grey[300], + theme.palette.grey[400], + ]; + + const darkTones = [ + theme.palette.grey[500], + theme.palette.grey[600], + theme.palette.grey[700], + theme.palette.grey[800], + theme.palette.grey[900], + ]; + + const tealTones = ["#BBD4D8", "#82B1B8", "#498D98", "#1E5A63", "#224348"]; + + const greenTones = ["#C4E9DC", "#93D7BF", "#62C5A1", "#358A6C", "#2F5F4D"]; + + const limeTones = ["#DDE8CC", "#C0D5A1", "#A3C276", "#6E8846", "#4F5E38"]; + + const yellowTones = ["#FDF0C5", "#FBE394", "#F9D764", "#B99A36", "#7A682E"]; + + const orangeTones = ["#FBE0C9", "#F8C79B", "#F5AD6E", "#B5763F", "#785334"]; + + const redTones = ["#F9CDCB", "#F5A3A0", "#F07975", "#B14845", "#763937"]; + + const purpleTones = ["#CBC5DF", "#A095C4", "#7565A9", "#453672", "#382F51"]; + + const blueTones = ["#C2CDE9", "#8FA3D7", "#5D79C5", "#30498B", "#2C395F"]; + + const toneArrays = [ + lightTones, + darkTones, + tealTones, + greenTones, + limeTones, + yellowTones, + orangeTones, + redTones, + purpleTones, + blueTones, + ]; + + return ( + <> + {isPickerOpen ? ( + setPickerOpen(false)} + anchorEl={anchorEl} + open={isPickerOpen} + /> + ) : ( + + {renderMenuItem(theme.palette.common.black, handleColorSelect)} + + + + {mainColors.map((presetColor) => ( + { + setColor(presetColor); + handleColorSelect(presetColor); + }} + /> + ))} + + + {toneArrays.map((tones, index) => ( + + {tones.map((presetColor) => ( + handleColorSelect(presetColor)} + /> + ))} + + ))} + + + + {t("color_picker.custom")} + + {recentColors.current.length > 0 ? ( + <> + {recentColors.current.map((recentColor) => ( + { + setColor(recentColor); + handleColorSelect(recentColor); + }} + /> + ))} + + ) : ( + + )} + setPickerOpen(true)} + title={t("color_picker.add")} + > + + + + + )} + + ); +}; + +const StyledMenu = styled(Menu)` + & .MuiPaper-root { + border-radius: 8px; + padding: 4px 0px; + margin-left: -4px; + max-width: 220px; + } + & .MuiList-root { + padding: 0; + } +`; + +export const MenuItemWrapper = styled(MenuItem)` + display: flex; + flex-direction: row; + justify-content: flex-start; + font-size: 12px; + gap: 8px; + width: calc(100% - 8px); + min-width: 172px; + margin: 0px 4px 4px 4px; + border-radius: 4px; + padding: 8px; + height: 32px; +`; + +export const MenuItemText = styled("div")` + color: #000; +`; + +export const MenuItemSquare = styled.div` + width: 16px; + height: 16px; + background-color: ${theme.palette.common.black}; + box-sizing: border-box; + margin-top: 0px; + border-radius: 4px; +`; + +export const ColorSquare = styled.div` + width: 16px; + height: 16px; + border: 1px solid ${theme.palette.grey["300"]}; + background-color: none; + box-sizing: border-box; + margin-top: 0px; + border-radius: 4px; +`; + +const ColorsWrapper = styled.div` + display: flex; + flex-direction: column; + margin: 4px; +`; + +const ColorList = styled.div` + display: flex; + flex-wrap: wrap; + flex-direction: row; + margin: 8px 8px 0px 8px; + justify-content: flex-start; + gap: 4px; +`; + +const ColorGrid = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-start; + margin: 8px; + gap: 4px; +`; + +const ColorGridCol = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 4px; +`; + +const RecentColorButton = styled.button<{ $color: string }>` + width: 16px; + height: 16px; + ${({ $color }): string => { + if ($color.toUpperCase() === "#FFFFFF") { + return `border: 1px solid ${theme.palette.grey["300"]};`; + } + return `border: 1px solid ${$color};`; + }} + background-color: ${({ $color }): string => { + return $color === "transparent" ? "none" : $color; + }}; + box-sizing: border-box; + margin-top: 0px; + border-radius: 4px; + &:hover { + cursor: pointer; + outline: 1px solid ${theme.palette.grey["300"]}; + outline-offset: 1px; + } +`; + +const HorizontalDivider = styled.div` + height: 0px; + width: 100%; + border-top: 1px solid ${theme.palette.grey["200"]}; +`; + +const RecentLabel = styled.div` + font-family: "Inter"; + font-size: 12px; + font-family: Inter; + margin: 8px 12px 0px 12px; + color: ${theme.palette.text.secondary}; +`; + +const RecentColorsList = styled.div` + display: flex; + flex-wrap: wrap; + flex-direction: row; + padding: 8px; + margin: 0px 4px; + justify-content: flex-start; + gap: 4px; +`; + +const StyledButton = styled("button")` + display: flex; + justify-content: center; + flex-wrap: wrap; + align-items: center; + border: none; + background: none; + font-size: 12px; + height: 16px; + width: 16px; + margin: 0; + padding: 0; + border-radius: 4px; + svg { + width: 16px; + height: 16px; + } + &:hover { + cursor: pointer; + outline: 1px solid ${theme.palette.grey["300"]}; + outline-offset: 1px; + } +`; + +const EmptyContainer = styled.div` + display: none; +`; + +export default ColorMenu; diff --git a/webapp/IronCalc/src/components/ColorPicker/ColorPicker.tsx b/webapp/IronCalc/src/components/ColorPicker/ColorPicker.tsx index 18ec8dc..ef68304 100644 --- a/webapp/IronCalc/src/components/ColorPicker/ColorPicker.tsx +++ b/webapp/IronCalc/src/components/ColorPicker/ColorPicker.tsx @@ -1,182 +1,340 @@ import styled from "@emotion/styled"; -import Popover, { type PopoverOrigin } from "@mui/material/Popover"; -import { Check } from "lucide-react"; -import type React from "react"; -import { useEffect, useRef, useState } from "react"; +import { Menu, MenuItem, Popover, type PopoverOrigin } from "@mui/material"; +import { Check, Plus } from "lucide-react"; +import { type JSX, useEffect, useRef, useState } from "react"; import { HexColorInput, HexColorPicker } from "react-colorful"; import { useTranslation } from "react-i18next"; import { theme } from "../../theme"; +// Types type ColorPickerProps = { - color: string; - onChange: (color: string) => void; - onClose: () => void; + color?: string; + onChange: (color: string | null) => void; + onClose?: () => void; anchorEl: React.RefObject; anchorOrigin?: PopoverOrigin; transformOrigin?: PopoverOrigin; open: boolean; + renderMenuItem?: ( + color: string, + handleColorSelect: (color: string | null) => void, + ) => JSX.Element; }; const colorPickerWidth = 240; -const colorfulHeight = 240; -const ColorPicker = (properties: ColorPickerProps) => { - const [color, setColor] = useState(properties.color); +// Main Component +const ColorPicker = ({ + color = theme.palette.common.black, + onChange, + onClose, + anchorEl, + anchorOrigin, + transformOrigin, + open, + renderMenuItem, +}: ColorPickerProps) => { + const [selectedColor, setSelectedColor] = useState(color); + const [isPickerOpen, setPickerOpen] = useState(false); + const [isMenuOpen, setMenuOpen] = useState(open && !isPickerOpen); const recentColors = useRef([]); - const { t } = useTranslation(); - const closePicker = (newColor: string): void => { - const maxRecentColors = 14; - const colors = recentColors.current.filter((c) => c !== newColor); - recentColors.current = [newColor, ...colors].slice(0, maxRecentColors); - properties.onChange(newColor); - }; - - const handleClose = (): void => { - properties.onClose(); - }; + useEffect(() => { + setSelectedColor(color); + }, [color]); useEffect(() => { - setColor(properties.color); - }, [properties.color]); + setMenuOpen(open && !isPickerOpen); + }, [open, isPickerOpen]); - const presetColors = [ + const handleColorSelect = (color: string | null) => { + if (color && !recentColors.current.includes(color)) { + const maxRecentColors = 14; + recentColors.current = [color, ...recentColors.current].slice( + 0, + maxRecentColors, + ); + } + setSelectedColor(color || theme.palette.common.black); + onChange(color); + setPickerOpen(false); + }; + + const handleClose = () => { + setPickerOpen(false); + if (onClose) onClose(); + }; + + // Colors definitions + const mainColors = [ "#FFFFFF", + "#272525", "#1B717E", - "#59B9BC", "#3BB68A", "#8CB354", "#F8CD3C", "#F2994A", "#EC5753", - "#D03627", "#523E93", "#3358B7", ]; + const lightTones = [ + theme.palette.grey[50], + theme.palette.grey[100], + theme.palette.grey[200], + theme.palette.grey[300], + theme.palette.grey[400], + ]; + + const darkTones = [ + theme.palette.grey[500], + theme.palette.grey[600], + theme.palette.grey[700], + theme.palette.grey[800], + theme.palette.grey[900], + ]; + + const tealTones = ["#BBD4D8", "#82B1B8", "#498D98", "#1E5A63", "#224348"]; + const greenTones = ["#C4E9DC", "#93D7BF", "#62C5A1", "#358A6C", "#2F5F4D"]; + const limeTones = ["#DDE8CC", "#C0D5A1", "#A3C276", "#6E8846", "#4F5E38"]; + const yellowTones = ["#FDF0C5", "#FBE394", "#F9D764", "#B99A36", "#7A682E"]; + const orangeTones = ["#FBE0C9", "#F8C79B", "#F5AD6E", "#B5763F", "#785334"]; + const redTones = ["#F9CDCB", "#F5A3A0", "#F07975", "#B14845", "#763937"]; + const purpleTones = ["#CBC5DF", "#A095C4", "#7565A9", "#453672", "#382F51"]; + const blueTones = ["#C2CDE9", "#8FA3D7", "#5D79C5", "#30498B", "#2C395F"]; + + const toneArrays = [ + lightTones, + darkTones, + tealTones, + greenTones, + limeTones, + yellowTones, + orangeTones, + redTones, + purpleTones, + blueTones, + ]; + + // Default menu item renderer + const defaultRenderMenuItem = ( + color: string, + handleColorSelect: (color: string | null) => void, + ) => ( + handleColorSelect(color)}> + + {t("color_picker.default")} + + ); + + // Render color picker or menu return ( - - - { - setColor(newColor); - }} - /> - - - - {"Hex"} - - {"#"} - { - setColor(newColor); - }} - /> - - - - - - - {presetColors.map((presetColor) => ( - { - setColor(presetColor); + <> + {isPickerOpen ? ( + + + { + setSelectedColor(newColor); }} /> - ))} - - - {recentColors.current.length > 0 ? ( - <> - {"Recent"} + + + {"Hex"} + + {"#"} + { + setSelectedColor(newColor); + }} + tabIndex={0} + /> + + + + + + + + {t("color_picker.cancel")} + + { + handleColorSelect(selectedColor); + handleClose(); + }} + > + + {t("color_picker.apply")} + + + + + ) : ( + + {(renderMenuItem || defaultRenderMenuItem)( + theme.palette.common.black, + handleColorSelect, + )} + + - {recentColors.current.map((recentColor) => ( + {mainColors.map((presetColor) => ( { - setColor(recentColor); + setSelectedColor(presetColor); + handleColorSelect(presetColor); }} /> ))} - - ) : ( -
- )} - - { - closePicker(color); - }} - > - - {t("color_picker.apply")} - - - - + + {toneArrays.map((tones, index) => ( + + {tones.map((presetColor) => ( + { + setSelectedColor(presetColor); + handleColorSelect(presetColor); + }} + /> + ))} + + ))} + + + + {t("color_picker.custom")} + + {recentColors.current.length > 0 ? ( + <> + {recentColors.current.map((recentColor) => ( + { + setSelectedColor(recentColor); + handleColorSelect(recentColor); + }} + /> + ))} + + ) : ( + + )} + setPickerOpen(true)} + title={t("color_picker.add")} + > + + + + + )} + ); }; -const Buttons = styled.div` - display: flex; - justify-content: flex-end; - margin: 8px; -`; - -const StyledButton = styled("div")` - cursor: pointer; - color: #ffffff; - background: #f2994a; - padding: 0px 10px; - height: 36px; - line-height: 36px; - border-radius: 4px; - display: flex; - align-items: center; - font-family: "Inter"; - font-size: 14px; - &:hover { - background: #d68742; +// Styled Components +const StyledMenu = styled(Menu)` + & .MuiPaper-root { + border-radius: 8px; + padding: 4px 0px; + margin-left: -4px; + max-width: 220px; + } + & .MuiList-root { + padding: 0; } `; -const RecentLabel = styled.div` - font-family: "Inter"; +const StylePopover = styled(Popover)` + & .MuiPaper-root { + border-radius: 8px; + padding: 0px; + margin-left: -4px; + max-width: 220px; + } +`; + +const MenuItemWrapper = styled(MenuItem)` + display: flex; + flex-direction: row; + justify-content: flex-start; font-size: 12px; - font-family: Inter; - margin: 8px 8px 0px 8px; - color: ${theme.palette.text.secondary}; + gap: 8px; + width: calc(100% - 8px); + min-width: 172px; + margin: 0px 4px 4px 4px; + border-radius: 4px; + padding: 8px; + height: 32px; +`; + +const MenuItemText = styled("div")` + color: #000; +`; + +const MenuItemSquare = styled.div` + width: 16px; + height: 16px; + background-color: ${theme.palette.common.black}; + box-sizing: border-box; + margin-top: 0px; + border-radius: 4px; +`; + +const ColorsWrapper = styled.div` + display: flex; + flex-direction: column; + margin: 4px; `; const ColorList = styled.div` display: flex; flex-wrap: wrap; flex-direction: row; - margin: 8px; + margin: 8px 8px 0px 8px; justify-content: flex-start; - gap: 4.7px; + gap: 4px; +`; + +const ColorGrid = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-start; + margin: 8px; + gap: 4px; +`; + +const ColorGridCol = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 4px; `; const RecentColorButton = styled.button<{ $color: string }>` @@ -189,7 +347,7 @@ const RecentColorButton = styled.button<{ $color: string }>` return `border: 1px solid ${$color};`; }} background-color: ${({ $color }): string => { - return $color; + return $color === "transparent" ? "none" : $color; }}; box-sizing: border-box; margin-top: 0px; @@ -207,20 +365,67 @@ const HorizontalDivider = styled.div` border-top: 1px solid ${theme.palette.grey["200"]}; `; +const RecentLabel = styled.div` + font-family: "Inter"; + font-size: 12px; + font-family: Inter; + margin: 8px 12px 0px 12px; + color: ${theme.palette.text.secondary}; +`; + +const RecentColorsList = styled.div` + display: flex; + flex-wrap: wrap; + flex-direction: row; + padding: 8px; + margin: 0px 4px; + justify-content: flex-start; + gap: 4px; +`; + +const StyledPlusButton = styled("button")` + display: flex; + justify-content: center; + flex-wrap: wrap; + align-items: center; + border: none; + background: none; + font-size: 12px; + height: 16px; + width: 16px; + margin: 0; + padding: 0; + border-radius: 4px; + svg { + width: 16px; + height: 16px; + } + &:hover { + cursor: pointer; + outline: 1px solid ${theme.palette.grey["300"]}; + outline-offset: 1px; + } +`; + +const EmptyContainer = styled.div` + display: none; +`; + +// Color Picker Dialog Styles const ColorPickerDialog = styled.div` background: ${theme.palette.background.default}; width: ${colorPickerWidth}px; padding: 0px; display: flex; flex-direction: column; - + max-width: 100%; & .react-colorful { - height: ${colorfulHeight}px; - width: ${colorPickerWidth}px; + height: 160px; + width: 100%; } & .react-colorful__saturation { border-bottom: none; - border-radius: 0px; + border-radius: 8px 8px 0px 0px; } & .react-colorful__hue { height: 8px; @@ -236,7 +441,59 @@ const ColorPickerDialog = styled.div` border-radius: 8px; height: 16px; width: 16px; - border-bottom: 1px solid #eee; + } +`; + +const Buttons = styled.div` + display: flex; + justify-content: flex-end; + margin: 8px; + gap: 8px; +`; + +const StyledButton = styled("div")` + cursor: pointer; + width: 100%; + color: ${theme.palette.primary.contrastText}; + background: ${theme.palette.primary.main}; + padding: 0px 10px; + height: 28px; + border-radius: 4px; + display: flex; + gap: 4px; + align-items: center; + justify-content: center; + font-family: "Inter"; + font-size: 12px; + &:hover { + background: #d68742; + } + svg { + max-width: 12px; + max-height: 12px; + } +`; + +const CancelButton = styled("div")` + cursor: pointer; + width: 100%; + color: ${theme.palette.grey[700]}; + background: ${theme.palette.grey[200]}; + padding: 0px 10px; + height: 28px; + border-radius: 4px; + display: flex; + gap: 4px; + align-items: center; + justify-content: center; + font-family: "Inter"; + font-size: 12px; + &:hover { + background: ${theme.palette.grey[300]}; + } + svg { + max-width: 12px; + max-height: 12px; } `; @@ -257,7 +514,7 @@ const HexLabel = styled.div` const HexColorInputBox = styled.div` display: inline-flex; flex-grow: 1; - width: 140px; + width: 100%; height: 28px; border: 1px solid ${theme.palette.grey["300"]}; border-radius: 5px; @@ -270,6 +527,23 @@ const HexColorInputBox = styled.div` } `; +const StyledHexColorInput = styled(HexColorInput)` + width: 100%; + border: none; + background: transparent; + outline: none; + font-family: ${theme.typography.button.fontFamily}; + font-size: 12px; + text-transform: uppercase; + text-align: right; + padding-right: 10px; + border-radius: 5px; + + &:focus { + border-color: #4298ef; + } +`; + const HexWrapper = styled.div` display: flex; gap: 8px; @@ -301,7 +575,7 @@ const Swatch = styled.div<{ $color: string }>` return `border: 1px solid ${$color};`; }} background-color: ${({ $color }): string => $color}; - width: 28px; + min-width: 28px; height: 28px; border-radius: 5px; `; diff --git a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx index ff78123..4701802 100644 --- a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx +++ b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx @@ -67,7 +67,7 @@ type ToolbarProperties = { onToggleVerticalAlign: (v: string) => void; onToggleWrapText: (v: boolean) => void; onCopyStyles: () => void; - onTextColorPicked: (hex: string) => void; + onTextColorPicked: (hex: string | null) => void; onFillColorPicked: (hex: string) => void; onNumberFormatPicked: (numberFmt: string) => void; onBorderChanged: (border: BorderOptions) => void; @@ -410,10 +410,10 @@ function Toolbar(properties: ToolbarProperties) { { properties.onClearFormatting(); }} + disabled={!canEdit} title={t("toolbar.clear_formatting")} > @@ -421,14 +421,25 @@ function Toolbar(properties: ToolbarProperties) { { properties.onDownloadPNG(); }} + disabled={!canEdit} title={t("toolbar.selected_png")} > + { + // Add your onClick handler logic here + }} + disabled={!canEdit} + title={t("toolbar.new_button")} + > + {/* Add your button icon or text here */} + { - properties.onFillColorPicked(color); + if (color !== null) { + properties.onFillColorPicked(color); + } setFillColorPickerOpen(false); }} onClose={() => { diff --git a/webapp/IronCalc/src/locale/en_us.json b/webapp/IronCalc/src/locale/en_us.json index 11cc0c7..db69a3a 100644 --- a/webapp/IronCalc/src/locale/en_us.json +++ b/webapp/IronCalc/src/locale/en_us.json @@ -121,6 +121,11 @@ "insert_column": "Insert column" }, "color_picker": { - "apply": "Apply" + "apply": "Add color", + "cancel": "Cancel", + "add": "Add new color", + "default": "Default color", + "no_fill": "No fill", + "custom": "Custom" } }