From 9e895baa2427bb27ce5a434e71d754b6f40ff231 Mon Sep 17 00:00:00 2001 From: nina992 <89770889+nina992@users.noreply.github.com> Date: Wed, 23 Aug 2023 11:00:07 +0300 Subject: [PATCH] feat(web): add color field component (#627) Co-authored-by: nina992 --- .../components/fields/ColorField/hooks.ts | 147 +++++++++ .../fields/ColorField/index.stories.tsx | 16 + .../components/fields/ColorField/index.tsx | 288 ++++++++++++++++++ .../components/fields/ColorField/types.ts | 19 ++ .../components/fields/ColorField/utils.ts | 28 ++ .../TextInput/index.tsx | 0 .../{properties => fields}/Toggle/index.tsx | 0 .../{properties => fields}/index.tsx | 0 .../tabs/publish/Nav/PublishModal/index.tsx | 2 +- .../Editor/tabs/widgets/Nav/index.tsx | 2 +- .../SidePanel/ContainerSettings/index.tsx | 2 +- .../tabs/widgets/SidePanel/Settings/index.tsx | 16 +- web/src/services/i18n/translations/en.yml | 1 + web/src/services/i18n/translations/ja.yml | 1 + 14 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 web/src/beta/components/fields/ColorField/hooks.ts create mode 100644 web/src/beta/components/fields/ColorField/index.stories.tsx create mode 100644 web/src/beta/components/fields/ColorField/index.tsx create mode 100644 web/src/beta/components/fields/ColorField/types.ts create mode 100644 web/src/beta/components/fields/ColorField/utils.ts rename web/src/beta/components/{properties => fields}/TextInput/index.tsx (100%) rename web/src/beta/components/{properties => fields}/Toggle/index.tsx (100%) rename web/src/beta/components/{properties => fields}/index.tsx (100%) diff --git a/web/src/beta/components/fields/ColorField/hooks.ts b/web/src/beta/components/fields/ColorField/hooks.ts new file mode 100644 index 0000000000..31ea98e0ef --- /dev/null +++ b/web/src/beta/components/fields/ColorField/hooks.ts @@ -0,0 +1,147 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import tinycolor from "tinycolor2"; + +import { Params, RGBA } from "./types"; +import { getChannelLabel, getChannelValue, getHexString } from "./utils"; + +export default ({ value, onChange }: Params) => { + const [colorState, setColor] = useState(); + const [rgba, setRgba] = useState(tinycolor(value).toRgb()); + const [tempColor, setTempColor] = useState(colorState); + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + const pickerRef = useRef(null); + + //Actions + + const handleChange = useCallback((newColor: RGBA) => { + const color = getHexString(newColor); + if (!color) return; + setTempColor(color); + setRgba(newColor); + }, []); + + const handleHexInput = useCallback( + (e: React.ChangeEvent) => { + e.preventDefault(); + setColor(e.target.value); + setRgba(tinycolor(e.target.value ?? colorState).toRgb()); + }, + [colorState], + ); + + const handleRgbaInput = useCallback( + (e: React.ChangeEvent) => { + e.preventDefault(); + + handleChange({ + ...rgba, + [e.target.name]: e.target.value ? Number(e.target.value) : undefined, + }); + }, + [handleChange, rgba], + ); + + const handleClose = useCallback(() => { + if (value || colorState) { + setColor(value ?? colorState); + setRgba(tinycolor(value ?? colorState).toRgb()); + } else { + setColor(undefined); + setRgba(tinycolor(colorState == null ? undefined : colorState).toRgb()); + } + setTempColor(undefined); + setOpen(false); + }, [value, colorState]); + + const handleSave = useCallback(() => { + if (!onChange) return; + if (tempColor && tempColor != value && tempColor != colorState) { + setColor(tempColor); + setRgba(tinycolor(tempColor).toRgb()); + onChange(tempColor); + setTempColor(undefined); + } else if (colorState != value && colorState) { + onChange(colorState); + } + setOpen(false); + }, [colorState, onChange, tempColor, value]); + + const handleHexSave = useCallback(() => { + const hexPattern = /^#?([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/; + if (colorState && hexPattern.test(colorState)) { + handleSave(); + } else { + value && setColor(value); + } + }, [colorState, handleSave, value]); + + //events + + const handleClick = useCallback(() => setOpen(!open), [open]); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleHexSave(); + } + }, + [handleHexSave], + ); + + //UseEffects + + useEffect(() => { + if (value) { + setColor(value); + setRgba(tinycolor(value).toRgb()); + } else { + setColor(undefined); + } + }, [value]); + + useEffect(() => { + if (!value) return; + if (rgba && tinycolor(rgba).toHex8String() !== value) { + setColor(tinycolor(rgba).toHex8String()); + } + }, [rgba]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (open && wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + if (colorState != value && !open) { + handleSave(); + } + handleClose(); + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [handleClose]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + wrapperRef, + pickerRef, + colorState, + open, + rgba, + getChannelLabel, + getChannelValue, + handleClose, + handleSave, + handleHexSave, + handleChange, + handleRgbaInput, + handleHexInput, + handleClick, + handleKeyPress, + }; +}; diff --git a/web/src/beta/components/fields/ColorField/index.stories.tsx b/web/src/beta/components/fields/ColorField/index.stories.tsx new file mode 100644 index 0000000000..8ff395bf12 --- /dev/null +++ b/web/src/beta/components/fields/ColorField/index.stories.tsx @@ -0,0 +1,16 @@ +import { action } from "@storybook/addon-actions"; +import { Meta, StoryObj } from "@storybook/react"; + +import ColorField from "."; + +const meta: Meta = { + component: ColorField, +}; + +export default meta; + +type Story = StoryObj; + +export const ColorFieldInput: Story = { + render: () => , +}; diff --git a/web/src/beta/components/fields/ColorField/index.tsx b/web/src/beta/components/fields/ColorField/index.tsx new file mode 100644 index 0000000000..e1724cc861 --- /dev/null +++ b/web/src/beta/components/fields/ColorField/index.tsx @@ -0,0 +1,288 @@ +import React from "react"; +import { RgbaColorPicker } from "react-colorful"; + +import Button from "@reearth/beta/components/Button"; +import Icon from "@reearth/beta/components/Icon"; +import * as Popover from "@reearth/beta/components/Popover"; +import Text from "@reearth/beta/components/Text"; +import { useT } from "@reearth/services/i18n"; +import { styled, css, useTheme } from "@reearth/services/theme"; + +import Property from ".."; + +import useHooks from "./hooks"; +import { Props, RGBA } from "./types"; + +// Constants +const channels = ["r", "g", "b", "a"]; +const hexPlaceholder = "#RRGGBBAA"; + +// Component +const ColorField: React.FC = ({ name, description, value, onChange }) => { + const t = useT(); + const theme = useTheme(); + const { + wrapperRef, + pickerRef, + colorState, + open, + rgba, + getChannelValue, + handleClose, + handleSave, + handleHexSave, + handleChange, + handleRgbaInput, + handleHexInput, + handleClick, + handleKeyPress, + } = useHooks({ value, onChange }); + + return ( + + + + + + + + + + + + + + + + Color Picker + + {handleClose && } + + + + + RGBA + + {channels.map(channel => ( + + ))} + + + + + + + + + + + + ); +}; + +// Styled Components +const Wrapper = styled.div` + text-align: center; + width: 100%; + cursor: pointer; +`; + +const InputWrapper = styled.div` + display: flex; + gap: 4px; + background: ${({ theme }) => theme.bg[1]}; +`; + +const Layers = styled.div` + position: relative; + min-width: 28px; + min-height: 28px; + border-radius: 4px; + border: 1px solid ${({ theme }) => theme.outline.weak}; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25) inset; +`; + +const layerStyle = css` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +`; + +const check = (color: string) => ` +linear-gradient( + 45deg, + ${color} 25%, + transparent 25%, + transparent 75%, + ${color} 25%, + ${color} +) +`; + +const CheckedPattern = styled.div` + background-color: ${({ theme }) => theme.outline.main}; + background-image: ${({ theme }) => check(theme.bg[3])}, ${({ theme }) => check(theme.bg[3])}; + background-position: 0 0, 6px 6px; + background-size: 12px 12px; + ${layerStyle}; +`; + +const Swatch = styled.div<{ c?: string }>` + background: ${({ c }) => c || "transparent"}; + ${layerStyle}; +`; + +const PickerWrapper = styled(Popover.Content)` + width: 286px; + height: 362px; + border: 1px solid ${({ theme }) => theme.outline.weak}; + border-radius: 4px; + background: ${({ theme }) => theme.bg[1]}; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5); +`; + +const HeaderWrapper = styled.div` + display: flex; + padding: 4px 8px; + justify-content: space-between; + align-items: center; + gap: 10px; + height: 28px; + border-bottom: 1px solid ${({ theme }) => theme.outline.weak}; +`; + +const PickerTitle = styled(Text)` + text-align: center; + margin-right: auto; +`; + +const CloseIcon = styled(Icon)` + margin-left: auto; + cursor: pointer; +`; + +const SelectorPickerWrapper = styled.div` + display: flex; + padding: 8px; + flex-direction: column; + align-items: flex-start; +`; + +const ValuesWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 4px; + padding-top: 8px; + padding-bottom: 8px; +`; + +const Input = styled.input<{ type?: string }>` + display: flex; + padding: 4px 8px; + align-items: center; + margin: 0 auto; + border-radius: 4px; + gap: 4px; + border: 1px solid ${({ theme }) => theme.outline.weak}; + color: ${({ theme }) => theme.content.main}; + background: ${({ theme }) => theme.bg[1]}; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25) inset; + box-sizing: border-box; + outline: none; + &:focus { + border-color: ${({ theme }) => theme.outline.main}; + } + width: 100%; + height: 30px; +`; + +const RgbaInputWrapper = styled.div` + display: flex; + height: 56px; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 8px; +`; +const FormButtonGroup = styled.div` + display: flex; + flex-direction: row; + height: 28px; + justify-content: center; + border-top: 1px solid ${({ theme }) => theme.bg[3]}; + padding: 8px; + gap: 8px; +`; +const ButtonWrapper = styled(Button)` + height: 27px; + min-width: 135px; + padding: 0px; + margin: 0px; +`; + +const ColorPickerStyles = css` + padding: 8px 8px 6px 0px; + gap: 12px; + + .react-colorful__saturation-pointer { + width: 15px; + height: 15px; + border-width: 2px; + } + + .react-colorful__hue-pointer, + .react-colorful__alpha-pointer { + width: 1px; + height: 10px; + border: 1px solid white; + border-radius: 2px; + } + + .react-colorful__saturation { + margin-bottom: 10px; + border-radius: 3px; + width: 270px; + border-bottom: none; + } + + .react-colorful__hue, + .react-colorful__alpha { + height: 10px; + width: 270px; + margin: 0 5px 10px 3px; + border-radius: 3px; + } +`; + +const ColorPicker = styled(RgbaColorPicker)` + ${ColorPickerStyles} +`; + +export default ColorField; diff --git a/web/src/beta/components/fields/ColorField/types.ts b/web/src/beta/components/fields/ColorField/types.ts new file mode 100644 index 0000000000..2be9ab1ca6 --- /dev/null +++ b/web/src/beta/components/fields/ColorField/types.ts @@ -0,0 +1,19 @@ +// Component Props +export type Props = { + name?: string; + description?: string; + value?: string; + onChange?: (value: string) => void; +}; + +export type RGBA = { + r: number; + g: number; + b: number; + a: number; +}; + +export type Params = { + value?: string; + onChange?: (value: string) => void; +}; diff --git a/web/src/beta/components/fields/ColorField/utils.ts b/web/src/beta/components/fields/ColorField/utils.ts new file mode 100644 index 0000000000..87322e43fa --- /dev/null +++ b/web/src/beta/components/fields/ColorField/utils.ts @@ -0,0 +1,28 @@ +import tinycolor, { ColorInput } from "tinycolor2"; + +import { RGBA } from "./types"; + +export const getHexString = (value?: ColorInput) => { + if (!value) return undefined; + const color = tinycolor(value); + return color.getAlpha() === 1 ? color.toHexString() : color.toHex8String(); +}; + +export const getChannelLabel = (channel: string) => { + switch (channel) { + case "r": + return "Red"; + case "g": + return "Green"; + case "b": + return "Blue"; + case "a": + return "Alpha"; + default: + return ""; + } +}; + +export function getChannelValue(rgba: RGBA, channel: keyof RGBA): number { + return rgba[channel]; +} diff --git a/web/src/beta/components/properties/TextInput/index.tsx b/web/src/beta/components/fields/TextInput/index.tsx similarity index 100% rename from web/src/beta/components/properties/TextInput/index.tsx rename to web/src/beta/components/fields/TextInput/index.tsx diff --git a/web/src/beta/components/properties/Toggle/index.tsx b/web/src/beta/components/fields/Toggle/index.tsx similarity index 100% rename from web/src/beta/components/properties/Toggle/index.tsx rename to web/src/beta/components/fields/Toggle/index.tsx diff --git a/web/src/beta/components/properties/index.tsx b/web/src/beta/components/fields/index.tsx similarity index 100% rename from web/src/beta/components/properties/index.tsx rename to web/src/beta/components/fields/index.tsx diff --git a/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx b/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx index b792755278..b4616842d1 100644 --- a/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx +++ b/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react"; import Button from "@reearth/beta/components/Button"; +import ToggleButton from "@reearth/beta/components/fields/Toggle"; import Icon from "@reearth/beta/components/Icon"; import Modal from "@reearth/beta/components/Modal"; -import ToggleButton from "@reearth/beta/components/properties/Toggle"; import Text from "@reearth/beta/components/Text"; import { useT } from "@reearth/services/i18n"; import { styled, metricsSizes, useTheme } from "@reearth/services/theme"; diff --git a/web/src/beta/features/Editor/tabs/widgets/Nav/index.tsx b/web/src/beta/features/Editor/tabs/widgets/Nav/index.tsx index bad3a2f407..5b0a8161f6 100644 --- a/web/src/beta/features/Editor/tabs/widgets/Nav/index.tsx +++ b/web/src/beta/features/Editor/tabs/widgets/Nav/index.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import Toggle from "@reearth/beta/components/properties/Toggle"; +import Toggle from "@reearth/beta/components/fields/Toggle"; import SecondaryNav from "@reearth/beta/features/Editor/SecondaryNav"; import { useT } from "@reearth/services/i18n"; import { selectedWidgetAreaVar } from "@reearth/services/state"; diff --git a/web/src/beta/features/Editor/tabs/widgets/SidePanel/ContainerSettings/index.tsx b/web/src/beta/features/Editor/tabs/widgets/SidePanel/ContainerSettings/index.tsx index 662078420e..7b33503715 100644 --- a/web/src/beta/features/Editor/tabs/widgets/SidePanel/ContainerSettings/index.tsx +++ b/web/src/beta/features/Editor/tabs/widgets/SidePanel/ContainerSettings/index.tsx @@ -1,4 +1,4 @@ -import TextInput from "@reearth/beta/components/properties/TextInput"; +import TextInput from "@reearth/beta/components/fields/TextInput"; import SidePanelSectionField from "@reearth/beta/components/SidePanelSectionField"; import { WidgetAreaPadding, WidgetAreaState } from "@reearth/services/state"; diff --git a/web/src/beta/features/Editor/tabs/widgets/SidePanel/Settings/index.tsx b/web/src/beta/features/Editor/tabs/widgets/SidePanel/Settings/index.tsx index 9e25554018..1e74e8f586 100644 --- a/web/src/beta/features/Editor/tabs/widgets/SidePanel/Settings/index.tsx +++ b/web/src/beta/features/Editor/tabs/widgets/SidePanel/Settings/index.tsx @@ -1,4 +1,5 @@ -import TextInput from "@reearth/beta/components/properties/TextInput"; +import ColorField from "@reearth/beta/components/fields/ColorField"; +import TextInput from "@reearth/beta/components/fields/TextInput"; import SidePanelSectionField from "@reearth/beta/components/SidePanelSectionField"; import { type Item } from "@reearth/services/api/propertyApi/utils"; import { styled } from "@reearth/services/theme"; @@ -23,7 +24,18 @@ const Settings: React.FC = ({ widgetPropertyId, propertyItems }) => { const value = !isList ? i.fields.find(f => f.id === sf.id)?.value : sf.defaultValue; return sf.type === "string" ? ( sf.ui === "color" ? ( -

Color field

+ ) : sf.ui === "selection" || sf.choices ? (

Selection or choices field

) : sf.ui === "buttons" ? ( diff --git a/web/src/services/i18n/translations/en.yml b/web/src/services/i18n/translations/en.yml index 7314e6378b..89d9d1d244 100644 --- a/web/src/services/i18n/translations/en.yml +++ b/web/src/services/i18n/translations/en.yml @@ -43,6 +43,7 @@ Wide: Wide Create New Workspace: Create New Workspace Create: Create Cancel: Cancel +Apply: Apply Workspace Name: Workspace Name Account Settings: Account Settings Workspaces: Workspaces diff --git a/web/src/services/i18n/translations/ja.yml b/web/src/services/i18n/translations/ja.yml index 19f6c65cdf..6a46015761 100644 --- a/web/src/services/i18n/translations/ja.yml +++ b/web/src/services/i18n/translations/ja.yml @@ -39,6 +39,7 @@ Wide: 広い Create New Workspace: 新規ワークスペース作成 Create: 作成 Cancel: キャンセル +Apply: Apply Workspace Name: ワークスペース名 Account Settings: アカウント設定 Workspaces: ワークスペース