diff --git a/web/src/beta/components/AdditionButton/index.stories.tsx b/web/src/beta/components/AdditionButton/index.stories.tsx new file mode 100644 index 0000000000..f386b1def7 --- /dev/null +++ b/web/src/beta/components/AdditionButton/index.stories.tsx @@ -0,0 +1,12 @@ +import { Story, Meta } from "@storybook/react"; +import { Component } from "react"; + +import AdditionButton, { Props } from "."; + +export default { + component: AdditionButton, +} as Meta; + +export const Default: Story = args => ; + +Default.args = {}; diff --git a/web/src/beta/components/AdditionButton/index.tsx b/web/src/beta/components/AdditionButton/index.tsx new file mode 100644 index 0000000000..50f9387a2a --- /dev/null +++ b/web/src/beta/components/AdditionButton/index.tsx @@ -0,0 +1,109 @@ +import React, { useRef, useCallback, useEffect } from "react"; +import { usePopper } from "react-popper"; + +import Icon from "@reearth/beta/components/Icon"; +import { styled } from "@reearth/services/theme"; + +import Portal from "../Portal"; + +export interface Props { + className?: string; + disabled?: boolean; + onClick?: () => void; + children?: React.ReactNode; +} + +const AdditionButton: React.FC = ({ className, children, disabled, onClick }) => { + const referenceElement = useRef(null); + const popperElement = useRef(null); + const { + styles, + attributes, + update: updatePopper, + } = usePopper(referenceElement.current, popperElement.current, { + placement: "bottom", + strategy: "fixed", + modifiers: [ + { + name: "eventListeners", + enabled: false, + options: { + scroll: false, + resize: false, + }, + }, + ], + }); + + const handleClick = useCallback(() => { + if (disabled) return; + onClick?.(); + }, [disabled, onClick]); + + // TODO: わかりずらい。もっといい方法ありそう。 + useEffect(() => { + if (children) { + updatePopper?.(); + } + }, [children, updatePopper]); + + return ( + + + + + + + +
+ {children} +
+
+
+ ); +}; + +const Wrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + * { + visibility: visible; + opacity: 1; + } + } +`; + +const InsertArea = styled.div` + width: 100%; + padding: 0 0 30px 0; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; + opacity: 0; + transition: all 0.5s; +`; + +const StyledIcon = styled(Icon)` + color: ${props => props.theme.infoBox.accent}; +`; + +const Button = styled.div` + color: ${props => props.theme.infoBox.accent}; + margin: 0 3px; +`; + +const Line = styled.div` + width: 43%; + background-color: ${props => props.theme.main.accent}; + height: 2px; + margin-top: -2px; +`; + +export default AdditionButton; diff --git a/web/src/beta/components/ContentPicker/README.md b/web/src/beta/components/ContentPicker/README.md new file mode 100644 index 0000000000..ee76aab7d7 --- /dev/null +++ b/web/src/beta/components/ContentPicker/README.md @@ -0,0 +1,11 @@ +# Contents Picker +[![Generic badge](https://img.shields.io/badge/GROUP-infobox-blue.svg)]() +[![Generic badge](https://img.shields.io/badge/SIZE-molecules-orange.svg)]() + +Contents Picker Modal for InfoBox + +## Usage + +## Properties + +## Screenshots diff --git a/web/src/beta/components/ContentPicker/index.stories.tsx b/web/src/beta/components/ContentPicker/index.stories.tsx new file mode 100644 index 0000000000..e9907fa84a --- /dev/null +++ b/web/src/beta/components/ContentPicker/index.stories.tsx @@ -0,0 +1,9 @@ +import { Meta } from "@storybook/react"; + +import ContentPicker from "."; + +export default { + component: ContentPicker, +} as Meta; + +export const Default = () => ; diff --git a/web/src/beta/components/ContentPicker/index.tsx b/web/src/beta/components/ContentPicker/index.tsx new file mode 100644 index 0000000000..d043d78991 --- /dev/null +++ b/web/src/beta/components/ContentPicker/index.tsx @@ -0,0 +1,115 @@ +import React, { useRef } from "react"; +import { useClickAway } from "react-use"; + +import Icon from "@reearth/beta/components/Icon"; +import Text from "@reearth/beta/components/Text"; +import { styled } from "@reearth/services/theme"; + +export interface Item { + id: string; + name: string; + icon?: string; +} + +export interface ContentsPickerProps { + className?: string; + items?: Item[]; + onClickAway?: () => void; + onSelect?: (index: number) => void; +} + +const ContentPicker: React.FC = ({ + className, + items: items, + onSelect, + onClickAway, +}) => { + const ref = useRef(null); + useClickAway(ref, () => onClickAway?.()); + return ( + + + {items?.map((item, i) => ( + + onSelect?.(i)}> + + {item.name} + + + ))} + {new Array((items ?? []).length % 3).fill(undefined).map((_, i) => ( + + ))} + + + ); +}; + +const Wrapper = styled.div` + background: ${props => props.theme.infoBox.bg}; + margin-top: 5px; + padding: 10px; + box-sizing: border-box; + border-radius: 3px; + width: 288px; + color: ${props => props.theme.infoBox.mainText}; + box-shadow: 0 0 5px ${props => props.theme.infoBox.deepBg}; + &:after { + content: ""; + position: absolute; + right: 0; + top: -5px; + left: 0; + width: 0px; + height: 0px; + margin: auto; + border-style: solid; + border-color: transparent transparent ${props => props.theme.infoBox.bg} transparent; + border-width: 0 10px 10px 10px; + } +`; + +const ContentsList = styled.div` + display: flex; + flex-wrap: wrap; + height: 100%; + max-height: 200px; + overflow: auto; +`; + +const ContentItem = styled.div` + flex: 0 0 33.33333%; + display: flex; + align-items: center; + justify-content: center; +`; + +const ContentButton = styled.div` + padding: 5px; + width: 60px; + border: solid 0.5px transparent; + box-sizing: border-box; + text-align: center; + cursor: pointer; + + &:hover { + border-radius: 6px; + border: solid 0.5px ${props => props.theme.main.select}; + } +`; + +const GhostButton = styled.div` + width: 30%; +`; + +const ButtonText = styled(Text)` + margin: 3px 0; + user-select: none; +`; + +const StyledIcon = styled(Icon)` + display: block; + margin: 0 auto; +`; + +export default ContentPicker; diff --git a/web/src/beta/components/DropHolder/index.tsx b/web/src/beta/components/DropHolder/index.tsx new file mode 100644 index 0000000000..aca90ef284 --- /dev/null +++ b/web/src/beta/components/DropHolder/index.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; + +export interface Props { + className?: string; +} + +const DropHolder: React.FC = ({ className }) => { + const t = useT(); + + return ( + + {t("Drop here")} + + ); +}; + +const DraggableView = styled.div` + position: absolute; + z-index: ${props => props.theme.zIndexes.dropDown}; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: ${props => props.theme.main.accent}; + opacity: 0.5; + display: flex; + align-items: center; + justify-content: center; +`; + +const DragMessage = styled.p` + color: ${props => props.theme.main.text}; + opacity: 1; +`; + +export default DropHolder; diff --git a/web/src/beta/components/Filled/index.ts b/web/src/beta/components/Filled/index.ts new file mode 100644 index 0000000000..a4a9fb95c0 --- /dev/null +++ b/web/src/beta/components/Filled/index.ts @@ -0,0 +1,10 @@ +import { styled } from "@reearth/services/theme"; + +const Filled = styled.div` + width: 100%; + height: 100%; + position: relative; + overflow: hidden; +`; + +export default Filled; diff --git a/web/src/beta/components/Flex/index.stories.tsx b/web/src/beta/components/Flex/index.stories.tsx new file mode 100644 index 0000000000..164a2bda21 --- /dev/null +++ b/web/src/beta/components/Flex/index.stories.tsx @@ -0,0 +1,43 @@ +import { Meta, Story } from "@storybook/react"; + +import Component, { Props } from "."; + +export default { + component: Component, +} as Meta; + +const ExampleDiv = () => ( + <> +
hoge
+
fuga
+ +); + +export const SpaceBetween: Story = args => ( + + + +); +export const GapChildren: Story = args => ( + +
hoge
+
fuga
+
+); +export const DirectionVertical: Story = args => ( + + + +); + +SpaceBetween.args = { + justify: "space-between", +}; + +GapChildren.args = { + gap: "20px", +}; + +DirectionVertical.args = { + direction: "column", +}; diff --git a/web/src/beta/components/Flex/index.tsx b/web/src/beta/components/Flex/index.tsx new file mode 100644 index 0000000000..a9fc3a80c5 --- /dev/null +++ b/web/src/beta/components/Flex/index.tsx @@ -0,0 +1,69 @@ +import { ReactNode, CSSProperties, AriaRole, AriaAttributes } from "react"; + +import { ariaProps } from "@reearth/beta/utils/aria"; + +export type Props = { + className?: string; + onClick?: () => void; + children?: ReactNode; + testId?: string; + role?: AriaRole; +} & FlexOptions & + AriaAttributes; + +export type FlexOptions = { + align?: CSSProperties["alignItems"]; + justify?: CSSProperties["justifyContent"]; + wrap?: CSSProperties["flexWrap"]; + direction?: CSSProperties["flexDirection"]; + basis?: CSSProperties["flexBasis"]; + grow?: CSSProperties["flexGrow"]; + shrink?: CSSProperties["flexShrink"]; + flex?: CSSProperties["flex"]; + gap?: CSSProperties["gap"]; +}; + +const Flex: React.FC = ({ + className, + onClick, + children, + testId, + align, + justify, + wrap, + direction, + basis, + grow, + shrink, + flex, + gap, + role, + ...props +}) => { + const aria = ariaProps(props); + + return ( +
+ {children} +
+ ); +}; + +export default Flex; diff --git a/web/src/beta/components/FloatedPanel/index.tsx b/web/src/beta/components/FloatedPanel/index.tsx new file mode 100644 index 0000000000..9a3a485ca2 --- /dev/null +++ b/web/src/beta/components/FloatedPanel/index.tsx @@ -0,0 +1,81 @@ +import { SerializedStyles } from "@emotion/react"; +import { useTransition, TransitionStatus } from "@rot1024/use-transition"; +import React, { useRef, useEffect } from "react"; +import { useClickAway } from "react-use"; + +import useBuffered from "@reearth/beta/utils/use-buffered"; +import { styled } from "@reearth/services/theme"; + +export type Props = { + className?: string; + visible?: boolean; + styles?: SerializedStyles; + onClose?: () => void; + onHover?: (hovered: boolean) => void; + onClickAway?: (e: Event) => void; + onEnter?: () => void; + onEntered?: () => void; + onExit?: () => void; + onExited?: () => void; +} & React.HTMLAttributes; + +const FloatedPanel: React.FC = ({ + className, + visible, + styles, + children, + onHover, + onClick, + onClickAway, + onEnter, + onEntered, + onExit, + onExited, +}) => { + const ref = useRef(null); + useClickAway(ref, e => onClickAway?.(e)); + const transition = useTransition(!!visible, 200, { + mountOnEnter: true, + unmountOnExit: true, + }); + + // visibleがtrueの時のみ更新することで、InfoBoxを閉じるアニメーションが走る際に中身が消えないようにする + const bChildren = useBuffered(children, !!visible); + const bStyles = useBuffered(styles, !!visible); + + useEffect(() => { + if (transition === "entering") onEnter?.(); + if (transition === "entered") onEntered?.(); + if (transition === "exiting") onExit?.(); + if (transition === "exited") onExited?.(); + }, [transition, onEnter, onEntered, onExit, onExited]); + + return transition === "unmounted" ? null : ( + onHover?.(true)} + onMouseLeave={() => onHover?.(false)} + transition={transition} + styles={bStyles}> + {bChildren} + + ); +}; + +const Wrapper = styled.div<{ + transition?: TransitionStatus; + styles?: SerializedStyles; +}>` + position: absolute; + transition: ${({ transition }) => + transition === "entering" || transition === "exiting" ? "all 0.2s ease" : ""}; + transform: ${({ transition }) => + transition === "entering" || transition === "entered" ? "translateX(0%)" : "translateX(100%)"}; + opacity: ${({ transition }) => + transition === "entering" || transition === "entered" ? "1" : "0"}; + ${({ styles }) => styles} +`; + +export default FloatedPanel; diff --git a/web/src/beta/components/Icon/Icons/arrowLeft.svg b/web/src/beta/components/Icon/Icons/arrowLeft.svg new file mode 100644 index 0000000000..f9739753a1 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/arrowLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/arrowLongLeft.svg b/web/src/beta/components/Icon/Icons/arrowLongLeft.svg new file mode 100644 index 0000000000..38ac0d695a --- /dev/null +++ b/web/src/beta/components/Icon/Icons/arrowLongLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/arrowLongRight.svg b/web/src/beta/components/Icon/Icons/arrowLongRight.svg new file mode 100644 index 0000000000..fe771f4c96 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/arrowLongRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/arrowRight.svg b/web/src/beta/components/Icon/Icons/arrowRight.svg new file mode 100644 index 0000000000..d58281cc93 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/arrowRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/arrowUpDown.svg b/web/src/beta/components/Icon/Icons/arrowUpDown.svg new file mode 100644 index 0000000000..1621a17de7 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/arrowUpDown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/beta/components/Icon/Icons/cancel.svg b/web/src/beta/components/Icon/Icons/cancel.svg new file mode 100644 index 0000000000..aeedf6bbc6 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/cancel.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/crosshair.svg b/web/src/beta/components/Icon/Icons/crosshair.svg new file mode 100644 index 0000000000..f6a43e12a4 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/crosshair.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/ellipse.svg b/web/src/beta/components/Icon/Icons/ellipse.svg new file mode 100644 index 0000000000..3ef64d1c8a --- /dev/null +++ b/web/src/beta/components/Icon/Icons/ellipse.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/infoboxHTMLIcon.svg b/web/src/beta/components/Icon/Icons/infoboxHTMLIcon.svg new file mode 100644 index 0000000000..a437c0ed20 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxHTMLIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/beta/components/Icon/Icons/infoboxIcon copy.svg b/web/src/beta/components/Icon/Icons/infoboxIcon copy.svg new file mode 100644 index 0000000000..ea693133a9 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxIcon copy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/beta/components/Icon/Icons/infoboxIcon.svg b/web/src/beta/components/Icon/Icons/infoboxIcon.svg new file mode 100644 index 0000000000..ea693133a9 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/beta/components/Icon/Icons/infoboxLocationIcon.svg b/web/src/beta/components/Icon/Icons/infoboxLocationIcon.svg new file mode 100644 index 0000000000..39539de592 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxLocationIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/beta/components/Icon/Icons/infoboxTableIcon.svg b/web/src/beta/components/Icon/Icons/infoboxTableIcon.svg new file mode 100644 index 0000000000..c59348d4e1 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxTableIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/infoboxTextIcon.svg b/web/src/beta/components/Icon/Icons/infoboxTextIcon.svg new file mode 100644 index 0000000000..50629e552e --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxTextIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/infoboxVideoIcon.svg b/web/src/beta/components/Icon/Icons/infoboxVideoIcon.svg new file mode 100644 index 0000000000..401e1fb882 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/infoboxVideoIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/play-left.svg b/web/src/beta/components/Icon/Icons/play-left.svg new file mode 100644 index 0000000000..65781e98e5 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/play-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/play-right.svg b/web/src/beta/components/Icon/Icons/play-right.svg new file mode 100644 index 0000000000..769f4b2c34 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/play-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/plusSquare.svg b/web/src/beta/components/Icon/Icons/plusSquare.svg new file mode 100644 index 0000000000..a110b04606 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/plusSquare.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/beta/components/Icon/Icons/primPhotoIcon.svg b/web/src/beta/components/Icon/Icons/primPhotoIcon.svg new file mode 100644 index 0000000000..50caf4969c --- /dev/null +++ b/web/src/beta/components/Icon/Icons/primPhotoIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/Icons/timeline.svg b/web/src/beta/components/Icon/Icons/timeline.svg new file mode 100644 index 0000000000..621bbdfddc --- /dev/null +++ b/web/src/beta/components/Icon/Icons/timeline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/web/src/beta/components/Icon/icons.ts b/web/src/beta/components/Icon/icons.ts index 254c224d9b..b358fd6d56 100644 --- a/web/src/beta/components/Icon/icons.ts +++ b/web/src/beta/components/Icon/icons.ts @@ -1,10 +1,60 @@ -// Dataset +/* eslint-disable import/order */ + +// Primitives +import PrimPhotoOverlay from "./Icons/primPhotoIcon.svg"; + +// Infobox Blocks +import Infobox from "./Icons/infoboxIcon.svg"; +import InfoHTML from "./Icons/infoboxHTMLIcon.svg"; +import InfoLocation from "./Icons/infoboxLocationIcon.svg"; +import InfoTable from "./Icons/infoboxTableIcon.svg"; +import InfoText from "./Icons/infoboxTextIcon.svg"; +import InfoVideo from "./Icons/infoboxVideoIcon.svg"; + +// Arrow +import ArrowUpDown from "./Icons/arrowUpDown.svg"; +import ArrowLeft from "./Icons/arrowLeft.svg"; +import ArrowRight from "./Icons/arrowRight.svg"; +import ArrowLongLeft from "./Icons/arrowLongLeft.svg"; +import ArrowLongRight from "./Icons/arrowLongRight.svg"; + +// Indicator +import Crosshair from "./Icons/crosshair.svg"; + +// Fields / Actions +import PlusSquare from "./Icons/plusSquare.svg"; +import Cancel from "./Icons/cancel.svg"; import ActionButton from "./Icons/actionButton.svg"; + +// Dataset import File from "./Icons/fileIcon.svg"; -// Fields / Actions +// Timeline +import Timeline from "./Icons/timeline.svg"; +import PlayRight from "./Icons/play-right.svg"; +import PlayLeft from "./Icons/play-left.svg"; +import Ellipse from "./Icons/ellipse.svg"; export default { file: File, + dl: InfoTable, + infobox: Infobox, + text: InfoText, + html: InfoHTML, + video: InfoVideo, + location: InfoLocation, + photooverlay: PrimPhotoOverlay, + arrowUpDown: ArrowUpDown, + arrowRight: ArrowRight, + arrowLeft: ArrowLeft, + arrowLongLeft: ArrowLongLeft, + arrowLongRight: ArrowLongRight, + cancel: Cancel, + crosshair: Crosshair, + plusSquare: PlusSquare, + ellipse: Ellipse, + playRight: PlayRight, + playLeft: PlayLeft, + timeline: Timeline, actionbutton: ActionButton, }; diff --git a/web/src/beta/components/Icon/index.stories.tsx b/web/src/beta/components/Icon/index.stories.tsx index 372f101b97..3d7ccdac39 100644 --- a/web/src/beta/components/Icon/index.stories.tsx +++ b/web/src/beta/components/Icon/index.stories.tsx @@ -6,7 +6,6 @@ const icon = ''; export default { - title: "Icon", component: Icon, } as Meta; diff --git a/web/src/beta/components/Icon/index.tsx b/web/src/beta/components/Icon/index.tsx index 1320454352..ba00f4a1f4 100644 --- a/web/src/beta/components/Icon/index.tsx +++ b/web/src/beta/components/Icon/index.tsx @@ -2,7 +2,7 @@ import svgToMiniDataURI from "mini-svg-data-uri"; import React, { AriaAttributes, AriaRole, CSSProperties, memo, useMemo } from "react"; import SVG from "react-inlinesvg"; -import { ariaProps } from "@reearth/classic/util/aria"; +import { ariaProps } from "@reearth/beta/utils/aria"; import { styled } from "@reearth/services/theme"; import Icons from "./icons"; diff --git a/web/src/beta/components/InsertionBar/index.tsx b/web/src/beta/components/InsertionBar/index.tsx new file mode 100644 index 0000000000..9235df93bb --- /dev/null +++ b/web/src/beta/components/InsertionBar/index.tsx @@ -0,0 +1,155 @@ +import { useRef, useCallback, useEffect, ReactNode } from "react"; +import { usePopper } from "react-popper"; + +import Icon from "@reearth/beta/components/Icon"; +import { styled, css } from "@reearth/services/theme"; + +import Portal from "../Portal"; + +export interface Props { + className?: string; + pos?: "top" | "bottom"; + mode?: "hidden" | "dragging" | "visible"; + onButtonClick?: () => void; + children?: ReactNode; +} + +const InsertionBar: React.FC = ({ + className, + children, + pos, + mode = "visible", + onButtonClick, +}) => { + const referenceElement = useRef(null); + const popperElement = useRef(null); + const { + styles, + attributes, + update: updatePopper, + } = usePopper(referenceElement.current, popperElement.current, { + placement: "bottom", + strategy: "fixed", + modifiers: [ + { + name: "eventListeners", + enabled: false, + options: { + scroll: false, + resize: false, + }, + }, + ], + }); + + const handleClick = useCallback(() => { + if (mode !== "visible") return; + onButtonClick?.(); + }, [mode, onButtonClick]); + + // TODO: わかりずらい。もっといい方法ありそう。 + useEffect(() => { + if (children) { + updatePopper?.(); + } + }, [children, updatePopper]); + + return ( + <> + + + + + + + +
+ {children} +
+
+ + ); +}; + +const StyledAddButton = styled(Icon)` + background: ${props => props.theme.infoBox.bg}; + cursor: pointer; + display: block; + user-select: nocolor: ${props => props.theme.infoBox.accent}; + padding: 0 3px; + `; + +const ButtonWrapper = styled.div<{ visible?: boolean; hovered?: boolean }>` + display: ${props => (props.visible ? "block" : "none")}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: ${props => (props.hovered ? props.theme.infoBox.mainText : props.theme.infoBox.accent)}; +`; + +const InsertLine = styled.div<{ circleVisible?: boolean }>` + position: absolute; + left: 15px; + right: 15px; + top: 50%; + transform: translateY(-50%); + height: 2px; + background: ${props => props.theme.main.accent}; + + &::before { + display: ${props => (props.circleVisible ? "block" : "none")}; + content: ""; + position: absolute; + left: 0; + top: -4px; + width: 6px; + height: 6px; + border: 2px solid ${props => props.theme.main.accent}; + border-radius: 50%; + background: ${props => props.theme.layers.bg}; + } +`; + +type WrapperProps = { + mode?: "visible" | "dragging" | "hidden"; + pos?: "top" | "bottom"; + hovered?: boolean; +}; + +const Wrapper = styled.div` + ${({ mode }) => + mode === "hidden" && + css` + visibility: hidden; + pointer-events: none; + `} + position: absolute; + left: 0; + width: 100%; + z-index: ${props => props.theme.zIndexes.infoBox}; + top: ${props => (props.pos === "top" ? "0%" : "100%")}; + transform: translateY(-50%); + height: 15px; + cursor: pointer; + + & .WORKAROUND_INSERTION_BAR { + opacity: ${props => (!props.hovered && props.mode === "visible" ? "0" : "1")}; + transition: all 0.5s; + } + + &:hover .WORKAROUND_INSERTION_BAR { + opacity: 1; + } +`; + +export default InsertionBar; diff --git a/web/src/beta/components/Markdown/index.stories.tsx b/web/src/beta/components/Markdown/index.stories.tsx new file mode 100644 index 0000000000..dcde6878b7 --- /dev/null +++ b/web/src/beta/components/Markdown/index.stories.tsx @@ -0,0 +1,28 @@ +import { Meta, Story } from "@storybook/react"; + +import Component, { Props } from "."; + +const markdown = ` +> A block quote with ~strikethrough~ and a URL: https://reactjs.org. + +* Lists +* [ ] todo +* [x] done + +A table: + +| a | b | +| - | - | +`; + +export default { + component: Component, + parameters: { actions: { argTypesRegex: "^on.*" } }, +} as Meta; + +export const Default: Story = args => ; + +Default.args = { + children: markdown, + backgroundColor: "#fff", +}; diff --git a/web/src/beta/components/Markdown/index.tsx b/web/src/beta/components/Markdown/index.tsx new file mode 100644 index 0000000000..43d1b554cd --- /dev/null +++ b/web/src/beta/components/Markdown/index.tsx @@ -0,0 +1,111 @@ +import React, { useMemo } from "react"; +import ReactMarkdown from "react-markdown"; +import gfm from "remark-gfm"; +import tinycolor from "tinycolor2"; + +import { Typography, typographyStyles } from "@reearth/beta/utils/value"; +import { styled } from "@reearth/services/theme"; + +export type Props = { + className?: string; + children?: string; + styles?: Typography; + backgroundColor?: string; + onClick?: () => void; + onDoubleClick?: () => void; +}; + +const plugins = [gfm]; + +const Markdown: React.FC = ({ + className, + styles, + backgroundColor, + children, + onClick, + onDoubleClick, +}) => { + const dark = useMemo( + () => (backgroundColor ? isDark(backgroundColor) : false), + [backgroundColor], + ); + + return ( + + + {children || ""} + + + ); +}; + +const Wrapper = styled.div<{ styles?: Typography; dark: boolean }>` + background-color: transparent; + color: inherit; + font-family: inherit; + font-size: inherit; + line-height: inherit; + + ${({ styles }) => typographyStyles(styles)} + + h1, + h2 { + border-bottom: none; + } + + code { + background-color: ${({ dark }) => + dark ? "rgba(240, 246, 252, 0.15)" : "rgba(27, 31, 35, 0.05)"}; + } + + .highlight pre, + pre { + background-color: ${({ dark }) => + color(dark ? "#161b22" : "#f6f8fa", 0.1, dark)}; // #161b22 #f6f8fa + } + + table tr { + background-color: inherit; + border-top-color: ${({ dark }) => color(dark ? "#272c32" : "#c6cbd1", 0.1, dark)}; + } + + table tr:nth-of-type(2n) { + background-color: ${({ dark }) => color(dark ? "#161b22" : "#f6f8fa", 0.1, dark)}; + } + + table td, + table th { + border-color: ${({ dark }) => color(dark ? "#3b434b" : "#dfe2e5", 0.1, dark)}; + } + + blockquote { + color: inherit; /* #8b949e #6a737d */ + border-left-color: ${({ dark }) => color(dark ? "#3b434b" : "#dfe2e5", 0.1, dark)}; + } + + hr { + background-color: ${({ dark }) => color(dark ? "#30363d" : "#e1e4e8", 0.1, dark)}; + } +`; + +const color = (hex: string, alpha: number, dark: boolean) => { + const color = tinycolor(hex).toRgb(); + const bg = dark ? 0 : 255; + + // out = a * alpha + b * (1 - alpha) + // a = (out - b * (1 - alpha)) / alpha + const nr = Math.floor((color.r - (1 - alpha) * bg) / alpha); + const ng = Math.floor((color.g - (1 - alpha) * bg) / alpha); + const nb = Math.floor((color.b - (1 - alpha) * bg) / alpha); + + return `rgba(${nr}, ${ng}, ${nb}, ${alpha})`; +}; + +const isDark = (hex: string): boolean => tinycolor(hex).isDark(); + +export default Markdown; diff --git a/web/src/beta/components/NotFound/index.stories.tsx b/web/src/beta/components/NotFound/index.stories.tsx new file mode 100644 index 0000000000..d312728678 --- /dev/null +++ b/web/src/beta/components/NotFound/index.stories.tsx @@ -0,0 +1,9 @@ +import { Meta } from "@storybook/react"; + +import NotFound from "."; + +export default { + component: NotFound, +} as Meta; + +export const Default = () => ; diff --git a/web/src/beta/components/NotFound/index.tsx b/web/src/beta/components/NotFound/index.tsx new file mode 100644 index 0000000000..e95975378a --- /dev/null +++ b/web/src/beta/components/NotFound/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +import { useT } from "@reearth/services/i18n"; + +const NotFound: React.FC = () => { + const t = useT(); + return
{t("Notfound")}
; +}; + +export default NotFound; diff --git a/web/src/beta/components/Portal/index.tsx b/web/src/beta/components/Portal/index.tsx new file mode 100644 index 0000000000..a6cf945952 --- /dev/null +++ b/web/src/beta/components/Portal/index.tsx @@ -0,0 +1,14 @@ +import React, { ReactNode, useLayoutEffect } from "react"; +import ReactDOM from "react-dom"; + +const Portal: React.FC<{ children?: ReactNode }> = ({ children }) => { + const [node, setNode] = React.useState(); + + useLayoutEffect(() => { + setNode(document.body); + }, []); + + return node ? ReactDOM.createPortal(children, node) : null; +}; + +export default Portal; diff --git a/web/src/beta/components/Slide/index.stories.tsx b/web/src/beta/components/Slide/index.stories.tsx new file mode 100644 index 0000000000..b0521ef255 --- /dev/null +++ b/web/src/beta/components/Slide/index.stories.tsx @@ -0,0 +1,34 @@ +import { Meta } from "@storybook/react"; +import { useState } from "react"; + +import { styled } from "@reearth/services/theme"; + +import Slide from "."; + +const Wrapper = styled.div` + width: 200px; + height: 200px; +`; + +const Page = styled.div` + width: 100%; + height: 100%; + background-color: ${({ color }) => color}; +`; + +export default { + component: Slide, +} as Meta; + +export const Default = () => { + const [pos, setPos] = useState(0); + return ( + + + setPos(1)} /> + setPos(2)} /> + setPos(0)} /> + + + ); +}; diff --git a/web/src/beta/components/Slide/index.tsx b/web/src/beta/components/Slide/index.tsx new file mode 100644 index 0000000000..7736105fad --- /dev/null +++ b/web/src/beta/components/Slide/index.tsx @@ -0,0 +1,47 @@ +import React, { ReactNode } from "react"; + +import { styled } from "@reearth/services/theme"; + +export type Props = { + className?: string; + children?: ReactNode; + pos?: number; +}; + +const Slide: React.FC = ({ className, children, pos }) => { + return ( + + + {React.Children.map(children, child => + React.isValidElement(child) ? {child} : null, + )} + + + ); +}; + +const Wrapper = styled.div` + width: 100%; + height: 100%; + overflow: hidden; +`; + +const Inner = styled.div<{ pos?: number }>` + position: relative; + transform: translateX(${({ pos }) => `-${(pos ?? 0) * 100}%`}); + transition: transform 0.1s ease; + display: flex; + flex-wrap: nowrap; + align-items: stretch; + width: 100%; + height: 100%; +`; + +const Page = styled.div` + flex: 0 0 100%; + width: 100%; + display: flex; + justify-content: space-between; +`; + +export default Slide; diff --git a/web/src/beta/components/Slider/README.md b/web/src/beta/components/Slider/README.md new file mode 100644 index 0000000000..540634c8ff --- /dev/null +++ b/web/src/beta/components/Slider/README.md @@ -0,0 +1,11 @@ +# Property Slider +[![Generic badge](https://img.shields.io/badge/GROUP-property-yellow.svg)]() +[![Generic badge](https://img.shields.io/badge/SIZE-atom-blue.svg)]() + +Slider for property + +> NOTE: Property以外にも使えると思っているので、利用シーンがあればglobalでも良い気がする + +## Usage + +## Properties diff --git a/web/src/beta/components/Slider/index.stories.tsx b/web/src/beta/components/Slider/index.stories.tsx new file mode 100644 index 0000000000..73c4a945df --- /dev/null +++ b/web/src/beta/components/Slider/index.stories.tsx @@ -0,0 +1,13 @@ +import { action } from "@storybook/addon-actions"; +import { Meta } from "@storybook/react"; + +import Slider from "."; + +export default { + component: Slider, +} as Meta; + +export const Default = () => ; +export const Frame = () => ( + +); diff --git a/web/src/beta/components/Slider/index.tsx b/web/src/beta/components/Slider/index.tsx new file mode 100644 index 0000000000..f907322850 --- /dev/null +++ b/web/src/beta/components/Slider/index.tsx @@ -0,0 +1,52 @@ +import RCSlider from "rc-slider"; +import React, { ComponentProps } from "react"; + +import { styled, css } from "@reearth/services/theme"; + +import "rc-slider/assets/index.css"; + +type Props = { + min: number; + max: number; + frame?: boolean; +} & Omit, "defaultValue">; + +const Slider: React.FC = ({ frame = false, ...props }) => ( + + + +); + +const Wrapper = styled.div<{ frame: boolean }>` + display: flex; + align-items: center; + border: ${({ frame, theme }) => (frame ? `solid 1px ${theme.properties.border}` : "none")}; + border-radius: 3px; + background: ${({ frame, theme }) => (frame ? theme.properties.bg : "transparent")}; + width: 100%; + flex: 1; + box-sizing: border-box; + ${({ frame }) => + frame && + css` + padding: 6px 12px; + margin-right: 5px; + `}; +`; + +const StyledSlider = styled(RCSlider)` + .rc-slider-handle { + background-color: ${({ theme }) => theme.slider.handle}; + border: ${({ theme }) => theme.slider.border}; + } + + .rc-slider-track { + background-color: ${({ theme }) => theme.slider.track}; + } + + .rc-slider-handle:focus { + box-shadow: none; + } +`; + +export default Slider; diff --git a/web/src/beta/components/Text/README.md b/web/src/beta/components/Text/README.md new file mode 100644 index 0000000000..abe788be17 --- /dev/null +++ b/web/src/beta/components/Text/README.md @@ -0,0 +1,82 @@ +# Property Name Text + +[![Generic badge](https://img.shields.io/badge/GROUP-property-yellow.svg)]() +[![Generic badge](https://img.shields.io/badge/SIZE-atom-blue.svg)]() + +Text for property + +> NOTE: Property 以外にも使えると思っているので、利用シーンがあれば global でも良い気がする + +## Usage + +```tsx +import Text from "@reearth/beta/components/Text"; + + + This is Text + ここはテクストです + + If you set the Text component as a paragraph, the line height will become 1.5. Otherwise, it will be the default line height of 1. + + ここはテクストです +``` + +- If you put text inside another component and need more complex styling such as hover effects you can override the color prop in favor of passing stylings from the parent. + +```jsx + window.location.assign("http://docs.reearth.io")} +> + + + {t("User guide")} + +; + +const LongBannerButton = styled(Flex)` + background: ${(props) => props.theme.main.paleBg}; + width: 100%; + color: ${(props) => props.theme.main.text}; + + &:hover { + background: ${(props) => props.theme.main.bg}; + color: ${(props) => props.theme.main.strongText}; + } +`; +``` + +## Properties + +### className: string + +### children: ReactNode + +### color: string + +### customColor: boolean + +### size: + +- xl: 28 +- l: 20 +- m: 16 +- s: 14 +- xs: 12 +- 2xs: 10 + +### isParagraph: boolean + +​ If isParagraph is true, the line height will be 1.5. Default is 1. + +### weight: + +- Normal: 400 +- Bold: 700 + +### otherProperties: + +​ Inline CSS, excluding: "color" | "fontFamily" | "fontSize" | "lineHeight" | "fontStyle" + +### onClick : function() diff --git a/web/src/beta/components/Text/index.stories.tsx b/web/src/beta/components/Text/index.stories.tsx new file mode 100644 index 0000000000..8d72738c1d --- /dev/null +++ b/web/src/beta/components/Text/index.stories.tsx @@ -0,0 +1,28 @@ +import { Meta, Story } from "@storybook/react"; + +import Text, { TextProps } from "."; + +export default { + component: Text, +} as Meta; + +export const LBold: Story = () => ( + + LBold + +); +export const MParagraph: Story = () => ( + + MParagraph + +); +export const MRegularRed: Story = () => ( + + MRegular red + +); +export const MRegularRedBgBlue: Story = () => ( + + MRegular red blue + +); diff --git a/web/src/beta/components/Text/index.tsx b/web/src/beta/components/Text/index.tsx new file mode 100644 index 0000000000..c108d02ce5 --- /dev/null +++ b/web/src/beta/components/Text/index.tsx @@ -0,0 +1,57 @@ +import { CSSProperties } from "react"; + +import { useTheme } from "@reearth/services/theme"; +import { FontWeight, typography, TypographySize } from "@reearth/services/theme/fonts"; + +type NonChangeableProperties = "color" | "fontFamily" | "fontSize" | "lineHeight" | "fontStyle"; + +type ChangeableProperties = Omit; + +export type TextProps = { + className?: string; + children: any; + color?: string; + customColor?: boolean; + size: TypographySize; + isParagraph?: boolean; + weight?: FontWeight; + otherProperties?: Partial; + onClick?: () => void; +}; + +const Text: React.FC = ({ + className, + children, + size, + color, + customColor, + isParagraph = false, + weight = "normal", + otherProperties, + onClick, +}) => { + const theme = useTheme(); + const defaultColor = theme.text.default; + const typographyBySize = typography[size]; + + // Default is "regular" + const Typography = isParagraph + ? "paragraph" in typographyBySize && typographyBySize.paragraph + : weight === "bold" && "bold" in typographyBySize + ? typographyBySize.bold + : typographyBySize.regular; + + return Typography ? ( + + {children} + + ) : null; +}; + +export default Text; diff --git a/web/src/beta/components/Timeline/ScaleList.test.tsx b/web/src/beta/components/Timeline/ScaleList.test.tsx new file mode 100644 index 0000000000..da87049135 --- /dev/null +++ b/web/src/beta/components/Timeline/ScaleList.test.tsx @@ -0,0 +1,142 @@ +import { expect, test } from "vitest"; + +import { truncMinutes } from "@reearth/beta/utils/time"; +import { render, screen } from "@reearth/test/utils"; + +import { EPOCH_SEC, GAP_HORIZONTAL, HOURS_SECS, SCALE_INTERVAL } from "./constants"; +import ScaleList from "./ScaleList"; + +const START_DATE = new Date("2022-07-03T12:21:21.100"); +const END_DATA = new Date("2022-07-04T12:21:21.100"); +const DIFF = END_DATA.getTime() - truncMinutes(START_DATE).getTime(); + +test("it should render memory and date label", () => { + // Convert epoch time to scale by every interval + const scaleCount = Math.trunc(DIFF / EPOCH_SEC / SCALE_INTERVAL); + const hoursCount = Math.trunc(HOURS_SECS / SCALE_INTERVAL); + + const { container } = render( + , + ); + + // eslint-disable-next-line testing-library/no-node-access, testing-library/no-container + const children = Array.from(container.querySelector("div")?.children || []); + expect(children.length).toBe(scaleCount + 1); + + const expectedStrongScaleLabelList = [ + "Jul 03 2022 12:00:00.00", + "Jul 03 2022 14:00:00.00", + "Jul 03 2022 16:00:00.00", + "Jul 03 2022 18:00:00.00", + "Jul 03 2022 20:00:00.00", + "Jul 03 2022 22:00:00.00", + "Jul 04 2022 00:00:00.00", + "Jul 04 2022 02:00:00.00", + "Jul 04 2022 04:00:00.00", + "Jul 04 2022 06:00:00.00", + "Jul 04 2022 08:00:00.00", + "Jul 04 2022 10:00:00.00", + ]; + + expectedStrongScaleLabelList.forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); + +test("it should render memory and date label when scaleInterval is changed", () => { + const scaleInterval = 60; + + // Convert epoch time to scale by every interval + const scaleCount = Math.trunc(DIFF / EPOCH_SEC / scaleInterval); + const hoursCount = Math.trunc(HOURS_SECS / scaleInterval); + + const { container } = render( + , + ); + + // eslint-disable-next-line testing-library/no-node-access, testing-library/no-container + const children = Array.from(container.querySelector("div")?.children || []); + expect(children.length).toBe(scaleCount + 1); + + const expectedStrongScaleLabelList = [ + "Jul 03 2022 12:00:00.00", + "Jul 03 2022 15:00:00.00", + "Jul 03 2022 18:00:00.00", + "Jul 03 2022 21:00:00.00", + "Jul 04 2022 00:00:00.00", + "Jul 04 2022 03:00:00.00", + "Jul 04 2022 06:00:00.00", + "Jul 04 2022 09:00:00.00", + ]; + + expectedStrongScaleLabelList.forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); + +test("it should render memory and date label when strongScaleMinutes is changed", () => { + const scaleInterval = 60; + const strongScaleMinutes = 1; + + // Convert epoch time to scale by every interval + const scaleCount = Math.trunc(DIFF / EPOCH_SEC / scaleInterval); + const hoursCount = Math.trunc(HOURS_SECS / scaleInterval); + + const { container } = render( + , + ); + + // eslint-disable-next-line testing-library/no-node-access, testing-library/no-container + const children = Array.from(container.querySelector("div")?.children || []); + expect(children.length).toBe(scaleCount + 1); + + const expectedStrongScaleLabelList = [ + "Jul 03 2022 12:00:00.00", + "Jul 03 2022 13:00:00.00", + "Jul 03 2022 14:00:00.00", + "Jul 03 2022 15:00:00.00", + "Jul 03 2022 16:00:00.00", + "Jul 03 2022 17:00:00.00", + "Jul 03 2022 18:00:00.00", + "Jul 03 2022 19:00:00.00", + "Jul 03 2022 20:00:00.00", + "Jul 03 2022 21:00:00.00", + "Jul 03 2022 22:00:00.00", + "Jul 03 2022 23:00:00.00", + "Jul 04 2022 00:00:00.00", + "Jul 04 2022 01:00:00.00", + "Jul 04 2022 02:00:00.00", + "Jul 04 2022 03:00:00.00", + "Jul 04 2022 04:00:00.00", + "Jul 04 2022 05:00:00.00", + "Jul 04 2022 06:00:00.00", + "Jul 04 2022 07:00:00.00", + "Jul 04 2022 08:00:00.00", + "Jul 04 2022 09:00:00.00", + ]; + + expectedStrongScaleLabelList.forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/web/src/beta/components/Timeline/ScaleList.tsx b/web/src/beta/components/Timeline/ScaleList.tsx new file mode 100644 index 0000000000..237a8b8e96 --- /dev/null +++ b/web/src/beta/components/Timeline/ScaleList.tsx @@ -0,0 +1,111 @@ +import { memo } from "react"; + +import Text from "@reearth/beta/components/Text"; +import { PublishTheme, styled } from "@reearth/services/theme"; + +import { EPOCH_SEC, STRONG_SCALE_WIDTH, NORMAL_SCALE_WIDTH, PADDING_HORIZONTAL } from "./constants"; +import { formatDateForTimeline } from "./utils"; + +type Props = { + publishedTheme?: PublishTheme; + gapHorizontal: number; +} & ScaleListInnerProps; + +const ScaleList: React.FC = ({ publishedTheme, gapHorizontal, ...props }) => { + return ( + + + + ); +}; + +type ScaleListInnerProps = { + publishedTheme?: PublishTheme; + start: Date; + scaleCount: number; + hoursCount: number; + scaleInterval: number; + strongScaleMinutes: number; +}; + +const ScaleListInner: React.FC = memo(function ScaleListPresenter({ + publishedTheme, + start, + scaleCount, + hoursCount, + scaleInterval, + strongScaleMinutes, +}) { + const lastStrongScaleIdx = scaleCount - strongScaleMinutes; + return ( + <> + {[...Array(scaleCount + 1)].map((_, idx) => { + const isHour = idx % hoursCount === 0; + const isStrongScale = idx % strongScaleMinutes === 0; + if (isStrongScale && idx < lastStrongScaleIdx) { + const label = formatDateForTimeline(start.getTime() + idx * EPOCH_SEC * scaleInterval, { + detail: true, + }); + + return ( + + + {label} + + + + ); + } + return ; + })} + + ); +}); + +export type StyledColorProps = { + publishedTheme: PublishTheme | undefined; +}; + +const ScaleContainer = styled.div` + display: flex; + width: 0; + + height: 30px; + align-items: flex-end; + will-change: auto; + padding-left: ${PADDING_HORIZONTAL}px; + ::after { + content: ""; + display: block; + padding-right: 1px; + height: 1px; + } +`; + +const LabeledScale = styled.div` + display: flex; + align-items: flex-end; + position: relative; + height: 100%; +`; + +const ScaleLabel = styled(Text)` + position: absolute; + top: 0; + left: 0; + color: ${({ theme, publishedTheme }) => publishedTheme?.mainText || theme.main.text}; + white-space: nowrap; +`; + +const Scale = styled.div<{ + isHour: boolean; + isStrongScale: boolean; +}>` + flex-shrink: 0; + width: ${({ isStrongScale }) => + isStrongScale ? `${STRONG_SCALE_WIDTH}px` : `${NORMAL_SCALE_WIDTH}px`}; + height: ${({ isHour }) => (isHour && "16px") || "12px"}; + background: ${({ theme }) => theme.colors.publish.dark.text.weak}; +`; + +export default ScaleList; diff --git a/web/src/beta/components/Timeline/constants.ts b/web/src/beta/components/Timeline/constants.ts new file mode 100644 index 0000000000..8f8be5eabb --- /dev/null +++ b/web/src/beta/components/Timeline/constants.ts @@ -0,0 +1,17 @@ +import { metricsSizes } from "@reearth/services/theme"; + +export const EPOCH_SEC = 1000; +export const PADDING_HORIZONTAL = metricsSizes["m"]; +export const BORDER_WIDTH = 1; +export const MAX_ZOOM_RATIO = 3; +export const STRONG_SCALE_WIDTH = 2; +export const NORMAL_SCALE_WIDTH = 1; +export const GAP_HORIZONTAL = 14; +export const HOURS_SECS = 3600; +export const SCALE_INTERVAL = 600; +export const DAY_SECS = 86400; +export const MINUTES_SEC = 60; +export const SCALE_ZOOM_INTERVAL = 5; +export const KNOB_SIZE = 25; + +export const SCALE_LABEL_WIDTH = 111; diff --git a/web/src/beta/components/Timeline/hooks.ts b/web/src/beta/components/Timeline/hooks.ts new file mode 100644 index 0000000000..31126b8eb2 --- /dev/null +++ b/web/src/beta/components/Timeline/hooks.ts @@ -0,0 +1,322 @@ +import React, { + ChangeEventHandler, + MouseEvent, + MouseEventHandler, + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, + WheelEventHandler, +} from "react"; + +import { truncMinutes } from "@reearth/beta/utils/time"; + +import { + BORDER_WIDTH, + DAY_SECS, + EPOCH_SEC, + GAP_HORIZONTAL, + MAX_ZOOM_RATIO, + PADDING_HORIZONTAL, + HOURS_SECS, + NORMAL_SCALE_WIDTH, + STRONG_SCALE_WIDTH, +} from "./constants"; +import { TimeEventHandler, Range } from "./types"; +import { calcScaleInterval, formatDateForTimeline } from "./utils"; + +const convertPositionToTime = (e: MouseEvent, { start, end }: Range) => { + const curTar = e.currentTarget; + const width = curTar.scrollWidth - (PADDING_HORIZONTAL + BORDER_WIDTH) * 2; + const rect = curTar.getBoundingClientRect(); + const clientX = e.clientX - rect.x; + const scrollX = curTar.scrollLeft; + const posX = clientX + scrollX - (PADDING_HORIZONTAL + BORDER_WIDTH); + const percent = posX / width; + const rangeDiff = end - start; + const sec = rangeDiff * percent; + return Math.min(Math.max(start, start + sec), end); +}; + +type InteractionOption = { + range: Range; + zoom: number; + scaleElement: RefObject; + setScaleWidth: React.Dispatch>; + setZoom: React.Dispatch>; + onClick?: TimeEventHandler; + onDrag?: TimeEventHandler; +}; + +const useTimelineInteraction = ({ + range: { start, end }, + zoom, + scaleElement, + setScaleWidth, + setZoom, + onClick, + onDrag, +}: InteractionOption) => { + const [isMouseDown, setIsMouseDown] = useState(false); + const handleOnMouseDown = useCallback(() => { + setIsMouseDown(true); + }, []); + const handleOnMouseUp = useCallback(() => { + setIsMouseDown(false); + }, []); + const handleOnMouseMove: MouseEventHandler = useCallback( + e => { + if (!isMouseDown || !onDrag || !e.target) { + return; + } + + onDrag(convertPositionToTime(e, { start, end })); + + const scrollThreshold = 30; + const scrollAmount = 20; + const rect = e.currentTarget.getBoundingClientRect(); + const clientX = e.clientX - rect.x; + const curTar = e.currentTarget; + const clientWidth = curTar.clientWidth; + + if (clientX > clientWidth - scrollThreshold) { + curTar.scroll(curTar.scrollLeft + scrollAmount, 0); + } + if (clientX < scrollThreshold) { + curTar.scroll(curTar.scrollLeft - scrollAmount, 0); + } + }, + [onDrag, start, end, isMouseDown], + ); + + useEffect(() => { + window.addEventListener("mouseup", handleOnMouseUp, { passive: true }); + return () => { + window.removeEventListener("mouseup", handleOnMouseUp); + }; + }, [handleOnMouseUp]); + + const handleOnClick: MouseEventHandler = useCallback( + e => { + if (!onClick) return; + onClick(convertPositionToTime(e, { start, end })); + }, + [onClick, end, start], + ); + + const handleOnWheel: WheelEventHandler = useCallback( + e => { + const { deltaX, deltaY } = e; + const isHorizontal = Math.abs(deltaX) > 0 || Math.abs(deltaX) < 0; + if (isHorizontal) return; + + setZoom(() => Math.min(Math.max(1, zoom + deltaY * -0.01), MAX_ZOOM_RATIO)); + }, + [zoom, setZoom], + ); + + useEffect(() => { + const elm = scaleElement.current; + if (!elm) return; + + const obs = new ResizeObserver(m => { + const target = m[0].target; + setScaleWidth(target.clientWidth); + }); + obs.observe(elm); + + return () => { + obs.disconnect(); + }; + }, [setScaleWidth, scaleElement]); + + return { + onMouseDown: handleOnMouseDown, + onMouseMove: handleOnMouseMove, + onClick: handleOnClick, + onWheel: handleOnWheel, + }; +}; + +type TimelinePlayerOptions = { + currentTime: number; + onPlay?: (isPlaying: boolean) => void; + onPlayReversed?: (isPlaying: boolean) => void; + onSpeedChange?: (speed: number) => void; +}; + +const useTimelinePlayer = ({ + currentTime, + onPlay, + onPlayReversed, + onSpeedChange, +}: TimelinePlayerOptions) => { + const [isPlaying, setIsPlaying] = useState(false); + const [isPlayingReversed, setIsPlayingReversed] = useState(false); + const syncCurrentTimeRef = useRef(currentTime); + const handleOnSpeedChange: ChangeEventHandler = useCallback( + e => { + onSpeedChange?.(parseInt(e.currentTarget.value, 10)); + }, + [onSpeedChange], + ); + const toggleIsPlaying = useCallback(() => { + if (isPlayingReversed) { + setIsPlayingReversed(false); + onPlayReversed?.(false); + } + setIsPlaying(p => !p); + onPlay?.(!isPlaying); + }, [isPlayingReversed, onPlay, isPlaying, onPlayReversed]); + const toggleIsPlayingReversed = useCallback(() => { + if (isPlaying) { + setIsPlaying(false); + onPlay?.(false); + } + setIsPlayingReversed(p => !p); + onPlayReversed?.(!isPlayingReversed); + }, [isPlaying, isPlayingReversed, onPlay, onPlayReversed]); + const formattedCurrentTime = useMemo(() => { + const textDate = formatDateForTimeline(currentTime, { detail: true }); + const lastIdx = textDate.lastIndexOf(" "); + const date = textDate.slice(0, lastIdx); + const time = textDate.slice(lastIdx); + return { + date, + time, + }; + }, [currentTime]); + + useEffect(() => { + syncCurrentTimeRef.current = currentTime; + }, [currentTime]); + + return { + onSpeedChange: handleOnSpeedChange, + formattedCurrentTime, + isPlaying, + isPlayingReversed, + toggleIsPlaying, + toggleIsPlayingReversed, + }; +}; + +const getRange = (range?: { [K in keyof Range]?: Range[K] }): Range => { + const { start, end } = range || {}; + if (start !== undefined && end !== undefined) { + return { start, end }; + } + + if (start !== undefined && end === undefined) { + return { + start, + end: start + DAY_SECS * EPOCH_SEC, + }; + } + + if (start === undefined && end !== undefined) { + return { + start: Math.max(end - DAY_SECS * EPOCH_SEC, 0), + end, + }; + } + + const defaultStart = Date.now(); + + return { + start: defaultStart, + end: defaultStart + DAY_SECS * EPOCH_SEC, + }; +}; + +type Option = { + currentTime: number; + range?: { [K in keyof Range]?: Range[K] }; + onClick?: TimeEventHandler; + onDrag?: TimeEventHandler; + onPlay?: (isPlaying: boolean) => void; + onPlayReversed?: (isPlaying: boolean) => void; + onSpeedChange?: (speed: number) => void; +}; + +export const useTimeline = ({ + currentTime, + range: _range, + onClick, + onDrag, + onPlay, + onPlayReversed, + onSpeedChange, +}: Option) => { + const range = useMemo(() => { + const range = getRange(_range); + if (process.env.NODE_ENV !== "production") { + if (range.start > range.end) { + throw new Error("Out of range error. `range.start` should be less than `range.end`"); + } + } + return { + start: truncMinutes(new Date(range.start)).getTime(), + end: truncMinutes(new Date(range.end)).getTime(), + }; + }, [_range]); + const { start, end } = range; + const [zoom, setZoom] = useState(1); + const startDate = useMemo(() => new Date(start), [start]); + const zoomedGap = GAP_HORIZONTAL * (zoom - Math.trunc(zoom) + 1); + const epochDiff = end - start; + + const scaleElement = useRef(null); + const [scaleWidth, setScaleWidth] = useState(0); + + const { + scaleCount, + scaleInterval, + strongScaleMinutes, + gap: gapHorizontal, + } = useMemo( + () => calcScaleInterval(epochDiff, zoom, { gap: zoomedGap, width: scaleWidth }), + [epochDiff, zoom, scaleWidth, zoomedGap], + ); + + // Count hours scale + const hoursCount = Math.trunc(HOURS_SECS / scaleInterval); + + // Convert scale count to pixel. + const currentPosition = useMemo(() => { + const diff = Math.min((currentTime - start) / EPOCH_SEC / scaleInterval, scaleCount); + const strongScaleCount = diff / strongScaleMinutes - 1; + return Math.max( + (diff - strongScaleCount) * (NORMAL_SCALE_WIDTH + gapHorizontal) + + strongScaleCount * (STRONG_SCALE_WIDTH + gapHorizontal), + 0, + ); + }, [currentTime, start, scaleCount, gapHorizontal, scaleInterval, strongScaleMinutes]); + + const events = useTimelineInteraction({ + range, + zoom, + setZoom, + onClick, + onDrag, + scaleElement, + setScaleWidth, + }); + const player = useTimelinePlayer({ currentTime, onPlay, onPlayReversed, onSpeedChange }); + + return { + startDate, + scaleCount, + hoursCount, + gapHorizontal, + scaleInterval, + strongScaleMinutes, + currentPosition, + events, + player, + scaleElement, + shouldScroll: zoom !== 1, + }; +}; diff --git a/web/src/beta/components/Timeline/index.stories.tsx b/web/src/beta/components/Timeline/index.stories.tsx new file mode 100644 index 0000000000..d50f84635e --- /dev/null +++ b/web/src/beta/components/Timeline/index.stories.tsx @@ -0,0 +1,43 @@ +import { Meta, Story } from "@storybook/react"; +import { useState } from "react"; + +import Timeline, { Props } from "."; + +export default { + component: Timeline, +} as Meta; + +export const Normal: Story = () => ( + +); + +export const DefaultRange: Story = () => ( + +); + +export const Movable: Story = () => { + // Forward a hour + const [currentTime, setCurrentTime] = useState(() => Date.now() + 3600000); + const [isOpened, setIsOpened] = useState(false); + return ( + setIsOpened(true)} + onClose={() => setIsOpened(false)} + /> + ); +}; diff --git a/web/src/beta/components/Timeline/index.test.tsx b/web/src/beta/components/Timeline/index.test.tsx new file mode 100644 index 0000000000..60e0354207 --- /dev/null +++ b/web/src/beta/components/Timeline/index.test.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { expect, test, vi, vitest } from "vitest"; + +import { render, screen, fireEvent } from "@reearth/test/utils"; + +import { PADDING_HORIZONTAL, BORDER_WIDTH, GAP_HORIZONTAL } from "./constants"; + +import Timeline from "."; + +global.ResizeObserver = class { + observe() {} + disconnect() {} +} as any; + +const CURRENT_TIME = new Date("2022-07-03T00:00:00.000").getTime(); +// This is width when range is one day. +const SCROLL_WIDTH = 2208; + +const TimelineWrapper: React.FC<{ + isOpened?: boolean; + onPlay?: () => void; + onPlayReversed?: () => void; + onSpeedChange?: (speed: number) => void; +}> = ({ isOpened = true, onPlay, onPlayReversed, onSpeedChange }) => { + const [currentTime, setCurrentTime] = useState(CURRENT_TIME); + return ( + + ); +}; + +test("it should open when timeline open button is clicked", () => { + const { rerender } = render(); + + expect(screen.queryAllByRole("slider")).toHaveLength(0); + + rerender(); + + expect(screen.queryAllByRole("slider")).not.toHaveLength(0); +}); + +test("it should get time from clicked position", () => { + render(); + const slider = screen.getAllByRole("slider")[1]; + const currentPosition = 12; + vi.spyOn(slider, "scrollWidth", "get").mockImplementation(() => SCROLL_WIDTH); + + fireEvent.click(slider, { + clientX: PADDING_HORIZONTAL + BORDER_WIDTH + currentPosition, + }); + + const iconWrapper = screen.getByTestId("knob-icon"); + expect(iconWrapper.style.left).toBe("-0.5px"); +}); + +test("it should get time from mouse moved position", () => { + render(); + const slider = screen.getAllByRole("slider")[1]; + const currentPosition = 12; + const clientX = PADDING_HORIZONTAL + BORDER_WIDTH + currentPosition; + const expectedLeft = "-0.5px"; + + const scroll = vi.fn(); + window.HTMLElement.prototype.scroll = scroll; + + vi.spyOn(slider, "scrollWidth", "get").mockImplementation(() => SCROLL_WIDTH); + + // Check initial position + expect( + Math.abs(Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10))), + ).toBe(0); + + // It should not move + fireEvent.mouseMove(slider, { + clientX, + }); + expect( + Math.abs(Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10))), + ).toBe(0); + + // It should move + fireEvent.mouseDown(slider); + fireEvent.mouseMove(slider, { + clientX, + }); + fireEvent.mouseUp(slider); + expect(screen.getByTestId("knob-icon").style.left).toBe(expectedLeft); + + // It should not move + fireEvent.mouseMove(slider, { + clientX: clientX * 2, + }); + expect(screen.getByTestId("knob-icon").style.left).toBe(expectedLeft); +}); + +test("it should get correct strongScaleHours from amount of scroll", () => { + render(); + const slider = screen.getAllByRole("slider")[1]; + vi.spyOn(slider, "scrollWidth", "get").mockImplementation(() => SCROLL_WIDTH); + + fireEvent.wheel(slider, { + deltaY: -50, + }); + expect( + parseInt(slider.querySelector("div")?.style.gap.split(" ")[1].split("px")[0] || "", 10), + ).toBe(GAP_HORIZONTAL * 1.5); + + // Reset gap and increase memory. + fireEvent.wheel(slider, { + deltaY: -50, + }); + expect( + parseInt(slider.querySelector("div")?.style.gap.split(" ")[1].split("px")[0] || "", 10), + ).toBe(GAP_HORIZONTAL); +}); + +test("it should invoke onPlay or onPlayReversed when play button is clicked", async () => { + const mockOnPlay = vitest.fn(); + const mockOnPlayReversed = vitest.fn(); + render(); + + // TODO: get element by label text + // Click play button + fireEvent.click(screen.getAllByRole("button")[2]); + expect(mockOnPlay).toBeCalledWith(true); + // Click play button again + fireEvent.click(screen.getAllByRole("button")[2]); + expect(mockOnPlay).toBeCalledWith(false); + + // TODO: get element by label text + // Click playback button + fireEvent.click(screen.getAllByRole("button")[1]); + expect(mockOnPlayReversed).toBeCalledWith(true); + // Click playback button again + fireEvent.click(screen.getAllByRole("button")[1]); + expect(mockOnPlayReversed).toBeCalledWith(false); + + // Click play button + fireEvent.click(screen.getAllByRole("button")[2]); + expect(mockOnPlay).toBeCalledWith(true); + // And click playback button + fireEvent.click(screen.getAllByRole("button")[1]); + expect(mockOnPlayReversed).toBeCalledWith(true); + expect(mockOnPlay).toBeCalledWith(false); + // Finally click play button + fireEvent.click(screen.getAllByRole("button")[2]); + expect(mockOnPlay).toBeCalledWith(true); + expect(mockOnPlayReversed).toBeCalledWith(false); +}); + +test("it should invoke onSpeedChange when speed range is changed", async () => { + const mockOnSpeedChange = vitest.fn(); + render(); + + // TODO: get element by label text + // Click play button + fireEvent.input(screen.getAllByRole("slider")[0], { target: { value: 100 } }); + + expect(mockOnSpeedChange).toBeCalledWith(100); +}); diff --git a/web/src/beta/components/Timeline/index.tsx b/web/src/beta/components/Timeline/index.tsx new file mode 100644 index 0000000000..01b2d7fcf1 --- /dev/null +++ b/web/src/beta/components/Timeline/index.tsx @@ -0,0 +1,321 @@ +import { memo } from "react"; + +import Icon from "@reearth/beta/components/Icon"; +import Text from "@reearth/beta/components/Text"; +import { useT } from "@reearth/services/i18n"; +import { styled, PublishTheme } from "@reearth/services/theme"; + +import { BORDER_WIDTH, PADDING_HORIZONTAL, KNOB_SIZE } from "./constants"; +import { useTimeline } from "./hooks"; +import ScaleList, { StyledColorProps } from "./ScaleList"; +import { Range, TimeEventHandler } from "./types"; + +export type { Range, TimeEventHandler } from "./types"; + +export type Props = { + /** + * @description + * This value need to be epoch time. + */ + currentTime: number; + /** + * @description + * These value need to be epoch time. + */ + range?: { [K in keyof Range]?: Range[K] }; + speed?: number; + isOpened?: boolean; + theme?: PublishTheme; + onClick?: TimeEventHandler; + onDrag?: TimeEventHandler; + onPlay?: (isPlaying: boolean) => void; + onPlayReversed?: (isPlaying: boolean) => void; + onOpen?: () => void; + onClose?: () => void; + onSpeedChange?: (speed: number) => void; +}; + +const Timeline: React.FC = memo(function TimelinePresenter({ + currentTime, + range, + speed, + isOpened, + theme, + onClick, + onDrag, + onPlay, + onPlayReversed, + onOpen, + onClose, + onSpeedChange: onSpeedChangeProps, +}) { + const { + startDate, + scaleCount, + hoursCount, + gapHorizontal, + scaleInterval, + strongScaleMinutes, + currentPosition, + scaleElement, + events, + shouldScroll, + player: { + formattedCurrentTime, + isPlaying, + isPlayingReversed, + toggleIsPlaying, + toggleIsPlayingReversed, + onSpeedChange, + }, + } = useTimeline({ + currentTime, + range, + onClick, + onDrag, + onPlay, + onPlayReversed, + onSpeedChange: onSpeedChangeProps, + }); + const t = useT(); + + return isOpened ? ( + +
+ + + +
+ +
  • + + + +
  • +
  • + + + +
  • +
  • + + + X{speed} + + + +
  • +
    + + + {formattedCurrentTime.date} + + + {formattedCurrentTime.time} + + + {/** + * TODO: Support keyboard operation for accessibility + * see: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/slider_role + */} + + + + + + +
    + ) : ( + + + + ); +}); + +const Container = styled.div` + background: ${({ theme, publishedTheme }) => publishedTheme?.background || theme.main.deepBg}; + width: 100%; + height: 40px; + display: flex; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +`; + +const OpenButton = styled.button` + background: ${({ theme, publishedTheme }) => publishedTheme?.background || theme.main.deepBg}; + color: ${({ theme, publishedTheme }) => publishedTheme?.mainText || theme.main.text}; + padding: 8px 12px; +`; + +const CloseButton = styled.button` + background: ${({ theme, publishedTheme }) => publishedTheme?.select || theme.main.select}; + color: ${({ theme, publishedTheme }) => publishedTheme?.mainText || theme.main.text}; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +`; + +const ToolBox = styled.ul` + display: flex; + align-items: center; + margin: ${({ theme }) => + `${theme.metrics.s}px ${theme.metrics.s}px ${theme.metrics.s}px ${theme.metrics.l}px`}; + list-style: none; + padding: 0; + + @media (max-width: 768px) { + margin-left: ${({ theme }) => `${theme.metrics.s}px`}; + } +`; + +const PlayButton = styled.button<{ isRight?: boolean; isPlaying?: boolean } & StyledColorProps>` + border-radius: 50%; + border-width: 1px; + border-style: solid; + border-color: ${({ theme, isPlaying, publishedTheme }) => + isPlaying ? publishedTheme?.select : publishedTheme?.mainText || theme.main.text}; + width: 22px; + height: 22px; + color: ${({ theme, publishedTheme }) => publishedTheme?.mainText || theme.main.text}; + display: flex; + align-items: center; + justify-content: center; + margin-left: ${({ isRight, theme }) => (isRight ? `${theme.metrics.s}px` : 0)}; + background: ${({ isPlaying, publishedTheme, theme }) => + isPlaying ? publishedTheme?.select || theme.main.select : "transparent"}; + + @media (max-width: 768px) { + margin-left: ${({ isRight, theme }) => (isRight ? `${theme.metrics.xs}px` : 0)}; + } +`; + +const InputRangeLabel = styled.label` + display: flex; + align-items: center; + justify-content: center; + margin-left: ${({ theme }) => theme.metrics["2xs"]}px; +`; + +const InputRangeLabelText = styled(Text)` + color: ${({ theme, publishedTheme }) => publishedTheme?.mainText || theme.main.text}; + /* space for preventing layout shift by increasing speed label. */ + width: 37px; + text-align: right; + margin-right: ${({ theme }) => theme.metrics.s}px; +`; + +const InputRange = styled.input` + -webkit-appearance: none; + background: ${({ theme }) => theme.main.weak}; + height: 1px; + width: 100px; + border: none; + ::-webkit-slider-thumb { + -webkit-appearance: none; + background: ${({ theme, publishedTheme }) => publishedTheme?.select || theme.main.select}; + height: 10px; + width: 10px; + border-radius: 50%; + } + + @media (max-width: 768px) { + width: 74px; + } +`; + +const CurrentTimeWrapper = styled.div` + border: ${({ theme }) => `1px solid ${theme.main.weak}`}; + border-radius: 4px; + padding: ${({ theme }) => `0 ${theme.metrics.s}px`}; + margin: ${({ theme }) => `${theme.metrics.xs}px 0`}; + flex-shrink: 0; + width: 70px; + + @media (max-width: 768px) { + display: none; + } +`; + +const CurrentTime = styled(Text)` + color: ${({ theme, publishedTheme }) => publishedTheme?.mainText || theme.main.text}; + line-height: 16px; + white-space: pre-line; +`; + +const ScaleBox = styled.div<{ shouldScroll: boolean }>` + border: ${({ theme }) => `${BORDER_WIDTH}px solid ${theme.main.weak}`}; + border-radius: 5px; + box-sizing: border-box; + position: relative; + overflow-x: ${({ shouldScroll }) => (shouldScroll ? "overlay" : "hidden")}; + overflow-y: hidden; + contain: strict; + width: 100%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transition: -webkit-scrollbar 1s; + ::-webkit-scrollbar { + display: none; + } + :hover::-webkit-scrollbar { + display: block; + height: 2px; + } + ::-webkit-scrollbar-track { + background-color: transparent; + background-color: red; + display: none; + } + ::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: ${({ theme }) => theme.colors.publish.dark.icon.main}; + } + margin: ${({ theme }) => + `${theme.metrics.xs}px ${theme.metrics.s}px ${theme.metrics.xs}px ${theme.metrics.xs}px`}; + + @media (max-width: 768px) { + margin-left: 0; + } +`; + +const IconWrapper = styled.div` + position: absolute; + top: 2px; + color: ${({ theme, publishedTheme }) => publishedTheme?.select || theme.main.select}; +`; + +export default Timeline; diff --git a/web/src/beta/components/Timeline/types.ts b/web/src/beta/components/Timeline/types.ts new file mode 100644 index 0000000000..cf95fa4bb3 --- /dev/null +++ b/web/src/beta/components/Timeline/types.ts @@ -0,0 +1,6 @@ +export type TimeEventHandler = (time: number) => void; + +export type Range = { + start: number; + end: number; +}; diff --git a/web/src/beta/components/Timeline/utils.test.ts b/web/src/beta/components/Timeline/utils.test.ts new file mode 100644 index 0000000000..4a5e9bd81f --- /dev/null +++ b/web/src/beta/components/Timeline/utils.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "vitest"; + +import { calcScaleInterval } from "./utils"; + +test("calcScaleInterval()", () => { + expect( + calcScaleInterval(new Date("2023-01-02").getTime() - new Date("2023-01-01").getTime(), 1, { + gap: 10, + width: 300, + }), + ).toEqual({ + gap: 10.358333333333333, + scaleCount: 24, + scaleInterval: 3600, + strongScaleMinutes: 10, + }); + expect( + calcScaleInterval(new Date("2023-01-02").getTime() - new Date("2023-01-01").getTime(), 2, { + gap: 10, + width: 300, + }), + ).toEqual({ + gap: 10, + scaleCount: 48, + scaleInterval: 1800, + strongScaleMinutes: 15, + }); +}); diff --git a/web/src/beta/components/Timeline/utils.ts b/web/src/beta/components/Timeline/utils.ts new file mode 100644 index 0000000000..88082ccd8e --- /dev/null +++ b/web/src/beta/components/Timeline/utils.ts @@ -0,0 +1,94 @@ +import { + BORDER_WIDTH, + EPOCH_SEC, + MINUTES_SEC, + NORMAL_SCALE_WIDTH, + PADDING_HORIZONTAL, + SCALE_LABEL_WIDTH, + STRONG_SCALE_WIDTH, +} from "./constants"; + +const MONTH_LABEL_LIST = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +export const formatDateForTimeline = (time: number, options: { detail?: boolean } = {}) => { + const d = new Date(time); + const year = d.getFullYear(); + const month = MONTH_LABEL_LIST[d.getMonth()]; + const date = `${d.getDate()}`.padStart(2, "0"); + const hour = `${d.getHours()}`.padStart(2, "0"); + if (!options.detail) { + return `${month} ${date} ${year} ${hour}:00:00.00`; + } + const minutes = `${d.getMinutes()}`.padStart(2, "0"); + const seconds = `${d.getSeconds()}`.padStart(2, "0"); + return `${month} ${date} ${year} ${hour}:${minutes}:${seconds}.00`; +}; + +const roundScaleInterval = (interval: number) => { + if (interval < 5) { + return 1; + } + if (5 <= interval && interval < 30) { + return 5 * Math.round(interval / 5); + } + if (30 <= interval && interval < 60) { + return 30 * Math.trunc(interval / 30); + } + return 60 * Math.round(interval / 60); +}; + +const DEFAULT_STRONG_SCALE_MINUTES = 10; +const ADDITIONAL_STRONG_SCALE_MINUTES = 5; +export const calcScaleInterval = ( + rangeDiff: number, + zoom: number, + styles: { width: number; gap: number }, +) => { + const timelineWidth = styles.width - (PADDING_HORIZONTAL + BORDER_WIDTH) * 2; + const scaleWidth = styles.gap + NORMAL_SCALE_WIDTH; + // Get number of scale that fits to current timeline width. + const numberOfScales = Math.round(timelineWidth / scaleWidth) - 1; + // Scale interval to round time like 30 mins, 1 hour + const scaleInterval = + roundScaleInterval(rangeDiff / (MINUTES_SEC * EPOCH_SEC) / numberOfScales) * MINUTES_SEC; + const zoomedScaleInterval = Math.max(scaleInterval / zoom, MINUTES_SEC); + + // convert epoch diff to minutes. + const scaleCount = rangeDiff / EPOCH_SEC / zoomedScaleInterval; + + // Adjust scale space gap. + const strongScaleCount = scaleCount / DEFAULT_STRONG_SCALE_MINUTES - 1; + const initialDisplayedWidth = + (scaleCount - strongScaleCount) * scaleWidth + + strongScaleCount * (styles.gap + STRONG_SCALE_WIDTH); + const initialRemainingGap = (timelineWidth - initialDisplayedWidth) / scaleCount; + + // To fit scale in initial width, adjusted gap is added only when zoom level is 1. + const nextGap = zoom === 1 ? styles.gap + initialRemainingGap : styles.gap; + + // Adjust strong scale position + const diffLabelWidth = Math.max(SCALE_LABEL_WIDTH - nextGap * DEFAULT_STRONG_SCALE_MINUTES, 0); + const strongScaleMinutes = + DEFAULT_STRONG_SCALE_MINUTES + + Math.floor(diffLabelWidth / DEFAULT_STRONG_SCALE_MINUTES) * ADDITIONAL_STRONG_SCALE_MINUTES; + + return { + scaleCount: Math.trunc(scaleCount), + scaleInterval: Math.trunc(zoomedScaleInterval), + strongScaleMinutes, + gap: nextGap, + }; +}; diff --git a/web/src/beta/core/Crust/Infobox/Block/DataList/index.stories.tsx b/web/src/beta/core/Crust/Infobox/Block/DataList/index.stories.tsx new file mode 100644 index 0000000000..2887df21fd --- /dev/null +++ b/web/src/beta/core/Crust/Infobox/Block/DataList/index.stories.tsx @@ -0,0 +1,56 @@ +import { Meta, Story } from "@storybook/react"; + +import DataList, { Item, Props } from "."; + +export default { + component: DataList, + parameters: { actions: { argTypesRegex: "^on.*" } }, +} as Meta; + +const items: Item[] = [ + { id: "a", item_title: "Name", item_datastr: "Foo bar", item_datatype: "string" }, + { id: "b", item_title: "Age", item_datanum: 20, item_datatype: "number" }, + { id: "c", item_title: "Address", item_datastr: "New York", item_datatype: "string" }, +]; + +const Template: Story = args => ; + +export const Default = Template.bind({}); +Default.args = { + block: { id: "", property: { items } }, +}; + +export const Title = Template.bind({}); +Title.args = { + block: { id: "", property: { title: "Title", items } }, +}; + +export const Typography: Story = Template.bind({}); +Typography.args = { + block: { + id: "", + property: { + typography: { color: "red", fontSize: 16 }, + items, + }, + }, +}; + +export const NoItems: Story = Template.bind({}); +NoItems.args = { isEditable: true }; + +export const Selected: Story = Template.bind({}); +Selected.args = { block: { id: "", property: { items } }, isSelected: true }; + +export const Editable: Story = Template.bind({}); +Editable.args = { + block: { id: "", property: { items } }, + isSelected: true, + isEditable: true, +}; + +export const Built: Story = Template.bind({}); +Built.args = { + block: { id: "", property: { items } }, + isBuilt: true, +}; diff --git a/web/src/beta/core/Crust/Infobox/Block/DataList/index.tsx b/web/src/beta/core/Crust/Infobox/Block/DataList/index.tsx new file mode 100644 index 0000000000..59c74662af --- /dev/null +++ b/web/src/beta/core/Crust/Infobox/Block/DataList/index.tsx @@ -0,0 +1,123 @@ +import React, { Fragment, useCallback, useState } from "react"; + +import Icon from "@reearth/beta/components/Icon"; +import { Typography, typographyStyles } from "@reearth/beta/utils/value"; +import { styled } from "@reearth/services/theme"; + +import { CommonProps as BlockProps } from ".."; +import { Border, Title } from "../utils"; + +export type Props = BlockProps; + +export type Item = { + id: string; + item_title?: string; + item_datatype?: "string" | "number"; + item_datastr?: string; + item_datanum?: number; +}; + +export type Property = { + title?: string; + typography?: Typography; + items?: Item[]; +}; + +const DataList: React.FC = ({ block, infoboxProperty, isSelected, isEditable, onClick }) => { + const { items } = block?.property ?? {}; + const { title, typography } = block?.property ?? {}; + const isTemplate = !title && !items; + + const [isHovered, setHovered] = useState(false); + const handleMouseEnter = useCallback(() => setHovered(true), []); + const handleMouseLeave = useCallback(() => setHovered(false), []); + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }, + [onClick], + ); + + return ( + + {isTemplate && isEditable ? ( + + ) : ( + <> + {title && {title}} +
    + {items?.map(i => ( + +
    {i.item_title}
    +
    {i.item_datatype === "number" ? i.item_datanum : i.item_datastr}
    +
    + ))} +
    + + )} +
    + ); +}; + +const Wrapper = styled(Border)<{ + typography?: Typography; +}>` + margin: 0 8px; + ${({ typography }) => typographyStyles({ ...typography })} + min-height: 70px; +`; + +const Dl = styled.dl` + display: flex; + flex-wrap: wrap; + min-height: 15px; +`; + +const Dt = styled.dt` + width: 30%; + padding: 10px; + padding-left: 0; + box-sizing: border-box; + font-weight: bold; + word-break: break-all; +`; + +const Dd = styled.dd` + width: 70%; + margin: 0; + padding: 10px; + padding-right: 0; + box-sizing: border-box; +`; + +const Template = styled.div` + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + width: 100%; + height: 185px; + margin: 0 auto; + user-select: none; +`; + +const StyledIcon = styled(Icon)<{ isSelected?: boolean; isHovered?: boolean }>` + color: ${props => + props.isHovered + ? props.theme.infoBox.border + : props.isSelected + ? props.theme.infoBox.accent2 + : props.theme.infoBox.weakText}; +`; + +export default DataList; diff --git a/web/src/beta/core/Crust/Infobox/Block/HTML/index.stories.tsx b/web/src/beta/core/Crust/Infobox/Block/HTML/index.stories.tsx new file mode 100644 index 0000000000..576ec6bdc7 --- /dev/null +++ b/web/src/beta/core/Crust/Infobox/Block/HTML/index.stories.tsx @@ -0,0 +1,33 @@ +import { Meta, Story } from "@storybook/react"; + +import HTML, { Props } from "."; + +export default { + component: HTML, + parameters: { actions: { argTypesRegex: "^on.*" } }, +} as Meta; + +const Template: Story = args => ; + +export const Default = Template.bind({}); +Default.args = { + block: { id: "", property: { html: "

    aaaaaa

    " } }, + isSelected: false, + isBuilt: false, + isEditable: false, +}; + +export const Title = Template.bind({}); +Title.args = { + block: { id: "", property: { html: "

    aaaaaa

    ", title: "Title" } }, + isSelected: false, + isBuilt: false, + isEditable: false, +}; + +export const NoText = Template.bind({}); +NoText.args = { + isSelected: false, + isBuilt: false, + isEditable: true, +}; diff --git a/web/src/beta/core/Crust/Infobox/Block/HTML/index.tsx b/web/src/beta/core/Crust/Infobox/Block/HTML/index.tsx new file mode 100644 index 0000000000..6295344947 --- /dev/null +++ b/web/src/beta/core/Crust/Infobox/Block/HTML/index.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from "react"; + +import Icon from "@reearth/beta/components/Icon"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; +import fonts from "@reearth/services/theme/fonts"; + +import { CommonProps as BlockProps } from ".."; +import { Border } from "../utils"; + +export type Props = BlockProps; + +export type Property = { + html?: string; + title?: string; +}; + +const HTMLBlock: React.FC = ({ + block, + isSelected, + isEditable, + infoboxProperty, + theme, + onChange, + onClick, +}) => { + const t = useT(); + const { html, title } = block?.property ?? {}; + + const ref = useRef(null); + const isDirty = useRef(false); + const [editingText, setEditingText] = useState(); + const isEditing = typeof editingText === "string"; + const isTemplate = !html && !title && !isEditing; + + const startEditing = useCallback(() => { + if (!isEditable) return; + setEditingText(html ?? ""); + }, [isEditable, html]); + + const finishEditing = useCallback(() => { + if (!isEditing) return; + if (onChange && isDirty.current) { + onChange("default", "html", editingText ?? "", "string"); + } + isDirty.current = false; + setEditingText(undefined); + }, [editingText, onChange, isEditing]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setEditingText(e.currentTarget.value); + isDirty.current = true; + }, + [], + ); + + useEffect(() => { + if (isEditing) { + ref.current?.focus(); + } + }, [isEditing]); + + const isSelectedPrev = useRef(false); + useEffect(() => { + if (isEditing && !isSelected && isSelectedPrev.current) { + finishEditing(); + } + }, [finishEditing, isSelected, isEditing]); + useEffect(() => { + isSelectedPrev.current = !!isSelected; + }, [isSelected]); + + const [isHovered, setHovered] = useState(false); + const handleMouseEnter = useCallback(() => setHovered(true), []); + const handleMouseLeave = useCallback(() => setHovered(false), []); + const handleClick = useCallback( + (e?: React.MouseEvent) => { + e?.stopPropagation(); + if (isEditing) return; + onClick?.(); + }, + [isEditing, onClick], + ); + + // iframe + const themeColor = infoboxProperty?.typography?.color ?? theme?.mainText; + const [frameRef, setFrameRef] = useState(null); + const [height, setHeight] = useState(15); + const initializeIframe = useCallback(() => { + const frameDocument = frameRef?.contentDocument; + const frameWindow = frameRef?.contentWindow; + if (!frameWindow || !frameDocument) { + return; + } + + if (!frameDocument.body.innerHTML.length) { + // `document.write()` is not recommended API by HTML spec, + // but we need to use this API to make it work correctly on Safari. + // If Safari supports `onLoad` event with `srcDoc`, we can remove this line. + frameDocument.write(html || ""); + } + + // Initialize styles + frameWindow.document.documentElement.style.margin = "0"; + + // Check if a style element has already been appended to the head + let style: HTMLElement | null = frameWindow.document.querySelector( + 'style[data-id="reearth-iframe-style"]', + ); + if (!style) { + // Create a new style element if it doesn't exist + style = frameWindow.document.createElement("style"); + style.dataset.id = "reearth-iframe-style"; + frameWindow.document.head.append(style); + } + // Update the content of the existing or new style element + style.textContent = `body { color:${themeColor ?? getComputedStyle(frameRef).color}; + font-family:Noto Sans, hiragino sans, hiragino kaku gothic proN, -apple-system, BlinkMacSystem, sans-serif; + font-size: ${fonts.sizes.s}px; } a { color:${themeColor ?? getComputedStyle(frameRef).color};}`; + + const handleFrameClick = () => handleClick(); + + if (isEditable) { + frameWindow.document.body.style.cursor = "pointer"; + frameWindow.document.addEventListener("dblclick", startEditing); + frameWindow.document.addEventListener("click", handleFrameClick); + } + + const resize = () => { + setHeight(frameWindow.document.documentElement.scrollHeight); + }; + + // Resize + const resizeObserver = new ResizeObserver(() => { + resize(); + }); + resizeObserver.observe(frameWindow.document.body); + + return () => { + frameWindow.document.removeEventListener("dblclick", startEditing); + frameWindow.document.removeEventListener("click", handleFrameClick); + resizeObserver.disconnect(); + }; + }, [frameRef, themeColor, isEditable, html, handleClick, startEditing]); + + useLayoutEffect(() => initializeIframe(), [initializeIframe]); + + return ( + + {isTemplate && isEditable && !isEditing ? ( + + ) : ( + <> + {title && {title}} + {isEditing ? ( + + ) : ( +