diff --git a/web/src/beta/components/fields/PropertyFields/index.tsx b/web/src/beta/components/fields/PropertyFields/index.tsx index 7be829a1c8..e2580fa16f 100644 --- a/web/src/beta/components/fields/PropertyFields/index.tsx +++ b/web/src/beta/components/fields/PropertyFields/index.tsx @@ -2,6 +2,7 @@ import TextInput from "@reearth/beta/components/fields/TextInput"; import { type Item } from "@reearth/services/api/propertyApi/utils"; import ColorField from "../ColorField"; +import SpacingInput, { SpacingValues } from "../SpacingInput"; import ToggleField from "../ToggleField"; import useHooks from "./hooks"; @@ -42,6 +43,16 @@ const PropertyFields: React.FC = ({ propertyId, item }) => { onChange={handlePropertyValueUpdate(item.schemaGroup, propertyId, sf.id, sf.type)} /> ) + ) : sf.type === "spacing" ? ( + ) : sf.type == "bool" ? ( ; + +export const Default = Template.bind({}); +Default.args = { + name: "Padding", + description: "Adjust the padding values", +}; + +export const WithValues = Template.bind({}); +WithValues.args = { + name: "Padding", + description: "Adjust the padding values", + value: { + top: "10", + left: "20", + right: "30", + bottom: "40", + }, +}; diff --git a/web/src/beta/components/fields/SpacingInput/index.tsx b/web/src/beta/components/fields/SpacingInput/index.tsx new file mode 100644 index 0000000000..ac4af706fe --- /dev/null +++ b/web/src/beta/components/fields/SpacingInput/index.tsx @@ -0,0 +1,87 @@ +import React, { useMemo, useState } from "react"; + +import { styled } from "@reearth/services/theme"; + +import Property from ".."; +import NumberInput from "../common/NumberInput"; + +export type SpacingValues = { + top: number; + left: number; + right: number; + bottom: number; +}; + +type Props = { + name?: string; + description?: string; + value?: SpacingValues; + min?: number; + max?: number; + onChange?: (values: SpacingValues) => void; +}; +type Position = keyof SpacingValues; + +const SpacingInput: React.FC = ({ name, description, value, min, max, onChange }) => { + const [spacingValues, setSpacingValues] = useState( + value || { top: 0, left: 0, right: 0, bottom: 0 }, + ); + + const memoizedSpacingValues = useMemo(() => { + return ["top", "left", "right", "bottom"].map(position => { + return getSpacingPosition(spacingValues, position as Position); + }); + }, [spacingValues]); + + const handleInputChange = (position: Position, newValue?: number) => { + const updatedValues = { ...spacingValues, [position]: newValue }; + setSpacingValues(updatedValues); + onChange?.(updatedValues); + }; + + return ( + + + {["top", "left", "right", "bottom"].map((position, index) => ( + handleInputChange(position as Position, newValue)} + /> + ))} + + + ); +}; + +export default SpacingInput; + +const StyledRectangle = styled.div` + display: flex; + width: 100%; + height: 94px; + border: 1px dashed ${({ theme }) => theme.outline.weak}; + box-sizing: border-box; + position: relative; +`; + +const SpacingField = styled(NumberInput)<{ position: string }>` + width: 40px; + position: absolute; + ${({ position }) => + position === "top" + ? "top: 0; left: 50%; transform: translateX(-50%);" + : position === "left" + ? "left: 0; top: 50%; transform: translateY(-50%);" + : position === "right" + ? "right: 0; top: 50%; transform: translateY(-50%);" + : "bottom: 0; left: 50%; transform: translateX(-50%);"}; +`; + +function getSpacingPosition(spacingValue: SpacingValues, position: Position): number { + return spacingValue[position]; +} diff --git a/web/src/beta/components/fields/common/NumberInput/index.stories.tsx b/web/src/beta/components/fields/common/NumberInput/index.stories.tsx new file mode 100644 index 0000000000..36769d59df --- /dev/null +++ b/web/src/beta/components/fields/common/NumberInput/index.stories.tsx @@ -0,0 +1,70 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import NumberInput, { Props } from "./index"; + +const meta: Meta = { + component: NumberInput, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 42, + inputDescription: "Value", + suffix: "px", + }, +}; + +export const Disabled: Story = { + args: { + value: 15, + inputDescription: "Disabled", + suffix: "px", + disabled: true, + }, +}; + +export const Range: Story = { + args: { + value: 50, + inputDescription: "Range", + suffix: "px", + min: 4, + max: 100, + }, +}; + +export const NoValue: Story = { + args: { + inputDescription: "No Value", + suffix: "px", + }, +}; + +export const WithMinValue: Story = { + args: { + value: 5, + inputDescription: "With Min Value", + suffix: "px", + min: 0, + }, +}; + +export const WithMaxValue: Story = { + args: { + value: 95, + inputDescription: "With Max Value", + suffix: "px", + max: 100, + }, +}; + +export const Editable: Story = { + args: { + value: 25, + inputDescription: "Editable", + suffix: "px", + }, +}; diff --git a/web/src/beta/components/fields/common/NumberInput/index.tsx b/web/src/beta/components/fields/common/NumberInput/index.tsx new file mode 100644 index 0000000000..2a9404bc76 --- /dev/null +++ b/web/src/beta/components/fields/common/NumberInput/index.tsx @@ -0,0 +1,176 @@ +import React, { useState, useCallback, useRef, useEffect } from "react"; + +import Text from "@reearth/beta/components/Text"; +import { useT } from "@reearth/services/i18n"; +import { useNotification } from "@reearth/services/state"; +import { styled, useTheme } from "@reearth/services/theme"; +import { metricsSizes } from "@reearth/services/theme/reearthTheme/common/metrics"; + +export type Props = { + className?: string; + suffix?: string; + min?: number; + max?: number; + disabled?: boolean; + inputDescription?: string; + value?: number; + onChange?: (value?: number | undefined) => void; +}; + +const NumberInput: React.FC = ({ + className, + value, + inputDescription, + suffix, + onChange, + min, + max, + disabled = false, +}) => { + const [innerValue, setInnerValue] = useState(value); + const [, setNotification] = useNotification(); + const inputRef = useRef(null); + + const isEditing = useRef(false); + const t = useT(); + + const theme = useTheme(); + + useEffect(() => { + setInnerValue(value); + }, [value]); + + useEffect(() => { + // Calculate and set the minimum width for the input field + if (inputRef.current) { + const minWidth = Math.max(metricsSizes.xs, inputRef.current.value.length * 10); + inputRef.current.style.width = `${minWidth}px`; + } + }, []); + + const handleValueChange = useCallback( + (newValue: number | undefined) => { + if (!onChange || !isEditing.current) { + return; + } + + if (newValue === undefined) { + setInnerValue(undefined); + onChange(undefined); + } else if (typeof max === "number" && isFinite(max) && newValue > max) { + setNotification({ type: "warning", text: t("You have passed the maximum value.") }); + setInnerValue(undefined); + onChange(undefined); + } else if (typeof min === "number" && isFinite(min) && newValue < min) { + setNotification({ type: "warning", text: t("You have passed the minimum value.") }); + setInnerValue(undefined); + onChange(undefined); + } else if (!isNaN(newValue)) { + setInnerValue(newValue); + onChange(newValue); + } + }, + [onChange, max, min, setNotification, t], + ); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.currentTarget.value; + setInnerValue(parseFloat(newValue)); + const minWidth = Math.max(metricsSizes.xs, newValue.length * 10); + e.currentTarget.style.width = `${minWidth}px`; + }, []); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleValueChange(parseFloat(e.currentTarget.value)); + } + }, + [handleValueChange], + ); + + const handleFocus = useCallback(() => { + isEditing.current = true; + }, []); + + const handleBlur = useCallback( + (e: React.SyntheticEvent) => { + const newValue = parseFloat(e.currentTarget.value); + handleValueChange(newValue); + isEditing.current = false; + }, + [handleValueChange], + ); + + return ( + + + + {suffix && ( + + {suffix} + + )} + + {inputDescription && ( + + {inputDescription} + + )} + + ); +}; + +const Wrapper = styled.div` + width: 100%; + text-align: center; +`; + +const InputWrapper = styled.div<{ inactive: boolean }>` + display: flex; + flex-direction: row; + align-items: center; + background: ${({ theme }) => theme.bg[1]}; + border: 1px solid ${({ theme }) => theme.outline.weak}; + border-radius: 4px; + padding: ${metricsSizes.xs}px ${metricsSizes.s}px; + gap: 12px; + width: auto; + min-width: min-content; + max-width: 64px; + color: ${({ inactive, theme }) => (inactive ? theme.content.weak : theme.content.main)}; + &:focus-within { + border-color: ${({ theme }) => theme.select.main}; + } + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25) inset; +`; + +const StyledInput = styled.input` + display: block; + border: none; + background: ${({ theme }) => theme.bg[1]}; + outline: none; + color: inherit; + width: 100%; + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + } + &[type="number"] { + -moz-appearance: textfield; + } +`; + +export default NumberInput; diff --git a/web/src/beta/components/fields/index.tsx b/web/src/beta/components/fields/index.tsx index 74f9f25e6d..ab777770fd 100644 --- a/web/src/beta/components/fields/index.tsx +++ b/web/src/beta/components/fields/index.tsx @@ -29,7 +29,7 @@ export default Property; const Wrapper = styled.div` display: flex; flex-direction: column; - gap: 4px; + gap: 8px; `; const Description = styled(Text)` diff --git a/web/src/beta/features/Editor/StoryPanel/ActionPanel/index.tsx b/web/src/beta/features/Editor/StoryPanel/ActionPanel/index.tsx index 9a068ebbdd..fe6716c005 100644 --- a/web/src/beta/features/Editor/StoryPanel/ActionPanel/index.tsx +++ b/web/src/beta/features/Editor/StoryPanel/ActionPanel/index.tsx @@ -191,7 +191,7 @@ const SettingsHeading = styled.div` `; const SettingsContent = styled.div` - height: 100px; + min-height: 134px; width: 200px; padding: 8px; box-sizing: border-box; diff --git a/web/src/services/i18n/translations/en.yml b/web/src/services/i18n/translations/en.yml index a714cd966b..ffe3259d39 100644 --- a/web/src/services/i18n/translations/en.yml +++ b/web/src/services/i18n/translations/en.yml @@ -454,3 +454,5 @@ Failed to update widget.: Failed to update widget. Failed to remove widget.: Failed to remove widget. Failed to update widget alignment.: Failed to update widget alignment. Failed to update the widget align system.: Failed to update the widget align system. +You have passed the maximum value.: You have passed the maximum value. +You have passed the minimum value.: You have passed the minimum value. \ No newline at end of file diff --git a/web/src/services/i18n/translations/ja.yml b/web/src/services/i18n/translations/ja.yml index dc2d335e1d..96d4a8d3f2 100644 --- a/web/src/services/i18n/translations/ja.yml +++ b/web/src/services/i18n/translations/ja.yml @@ -415,3 +415,5 @@ Failed to update widget.: ウィジェットのアップデートに失敗しま Failed to remove widget.: ウィジェットの削除に失敗しました。 Failed to update widget alignment.: ウィジェットのアラインメントのアップデートに失敗しました。 Failed to update the widget align system.: ウィジェットのアラインシステムのアップデートに失敗しました。 +You have passed the maximum value.: You have passed the maximum value. +You have passed the minimum value.: You have passed the minimum value. \ No newline at end of file