diff --git a/web/src/beta/components/Resizable/hooks.ts b/web/src/beta/components/Resizable/hooks.ts new file mode 100644 index 0000000000..408fe35c6b --- /dev/null +++ b/web/src/beta/components/Resizable/hooks.ts @@ -0,0 +1,118 @@ +import React, { useCallback, useState, useEffect, useMemo } from "react"; + +type Direction = "vertical" | "horizontal"; +type Gutter = "start" | "end"; + +const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent) => { + const { nativeEvent } = e; + if (nativeEvent instanceof MouseEvent) { + const { clientX: x, clientY: y, which } = nativeEvent; + + // When user click with right button the resize is stuck in resizing mode until users clicks again, dont continue if right click is used. + // https://github.com/bokuweb/re-resizable/blob/06dd269f835a201b03b4f62f37533784d855fdd2/src/index.tsx#L611 + if (which === 3) return; + + return { x, y }; + } + + if (nativeEvent instanceof TouchEvent) { + const touch = nativeEvent.touches[0]; + const { clientX: x, clientY: y } = touch; + + return { x, y }; + } + + return; +}; + +const getDelta = (direction: Direction, deltaX: number, deltaY: number) => + direction === "vertical" ? deltaX : deltaY; + +const getSize = (size: number, delta: number, minSize?: number, maxSize?: number) => { + if (minSize !== undefined && size + delta < minSize) return minSize; + if (maxSize !== undefined && size + delta > maxSize) return maxSize; + return size + delta; +}; + +export default ( + direction: Direction, + gutter: Gutter, + initialSize: number, + minSize?: number, + maxSize?: number, +) => { + const [isResizing, setIsResizing] = useState(false); + const [size, setSize] = useState(initialSize); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const onResizeStart = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + const position = getPositionFromEvent(e); + if (!position) return; + + setIsResizing(true); + setPosition(position); + }, + [], + ); + + const onResize = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!isResizing) return; + + const { clientX: x, clientY: y } = e instanceof MouseEvent ? e : e.touches[0]; + const deltaX = gutter === "start" ? position.x - x : x - position.x; + const deltaY = gutter === "start" ? position.y - y : y - position.y; + const delta = getDelta(direction, deltaX, deltaY); + + setSize(getSize(size, delta, minSize, maxSize)); + setPosition({ x, y }); + }, + [isResizing, position, direction, gutter, size, minSize, maxSize], + ); + + let unbind = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function + + const onResizeEnd = useCallback(() => { + if (!isResizing) return; + + setIsResizing(false); + setPosition({ x: 0, y: 0 }); + unbind(); + }, [isResizing]); + + const bindEventListeners = useCallback(() => { + if (typeof window === "undefined") return; + window.addEventListener("mouseup", onResizeEnd); + window.addEventListener("mousemove", onResize); + window.addEventListener("mouseleave", onResizeEnd); + window.addEventListener("touchmove", onResize); + window.addEventListener("touchend", onResizeEnd); + }, [onResize, onResizeEnd]); + + const unbindEventListeners = useCallback(() => { + if (typeof window === "undefined") return; + window.removeEventListener("mouseup", onResizeEnd); + window.removeEventListener("mousemove", onResize); + window.removeEventListener("mouseleave", onResizeEnd); + window.removeEventListener("touchmove", onResize); + window.removeEventListener("touchend", onResizeEnd); + }, [onResize, onResizeEnd]); + + unbind = unbindEventListeners; + + useEffect(() => { + bindEventListeners(); + return () => unbindEventListeners(); + }); + + const gutterProps = useMemo( + () => ({ + onMouseDown: (e: React.MouseEvent) => onResizeStart(e), + onTouchStart: (e: React.TouchEvent) => onResizeStart(e), + }), + [onResizeStart], + ); + + return { size, gutterProps }; +}; diff --git a/web/src/beta/components/Resizable/index.stories.tsx b/web/src/beta/components/Resizable/index.stories.tsx new file mode 100644 index 0000000000..d34b2dd96b --- /dev/null +++ b/web/src/beta/components/Resizable/index.stories.tsx @@ -0,0 +1,55 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { ReactNode, CSSProperties, ComponentProps } from "react"; + +import Resizable from "."; + +const Container: React.FC<{ children?: ReactNode; style?: CSSProperties }> = ({ + children, + style, +}) =>
{children}
; +const Pane =
; +const Content = ( +
+ content +
+); + +export default { + component: Resizable, +} as Meta>; + +export const Vertical: StoryObj = { + args: { + direction: "vertical", + gutter: "end", + size: 400, + minSize: 300, + maxSize: 500, + }, + render: args => { + return ( + + {Content} + {Pane} + + ); + }, +}; + +export const Horizontal: StoryObj = { + args: { + direction: "horizontal", + gutter: "end", + size: 200, + minSize: 100, + maxSize: 300, + }, + render: args => { + return ( + + {Content} + {Pane} + + ); + }, +}; diff --git a/web/src/beta/components/Resizable/index.tsx b/web/src/beta/components/Resizable/index.tsx new file mode 100644 index 0000000000..4e642469a2 --- /dev/null +++ b/web/src/beta/components/Resizable/index.tsx @@ -0,0 +1,78 @@ +import { ReactNode } from "react"; + +import { styled } from "@reearth/services/theme"; + +import useHooks from "./hooks"; + +type Props = { + children?: ReactNode; + direction: "vertical" | "horizontal"; + gutter: "start" | "end"; + size: number; + minSize?: number; + maxSize?: number; +}; + +const Resizable: React.FC = ({ + direction, + gutter, + size: initialSize, + minSize, + maxSize, + children, +}) => { + const { size, gutterProps } = useHooks(direction, gutter, initialSize, minSize, maxSize); + + const showTopGutter = direction === "horizontal" && gutter === "start"; + const showRightGutter = direction === "vertical" && gutter === "end"; + const showBottomGutter = direction === "horizontal" && gutter === "end"; + const showLeftGutter = direction === "vertical" && gutter === "start"; + + const TopGutter = showTopGutter ? : null; + const RightGutter = showRightGutter ? : null; + const BottomGutter = showBottomGutter ? : null; + const LeftGutter = showLeftGutter ? : null; + + return ( + + {TopGutter} + {LeftGutter} + {children} + {RightGutter} + {BottomGutter} + + ); +}; + +const StyledResizable = styled.div>` + display: flex; + align-items: stretch; + flex-direction: ${({ direction }) => (direction === "vertical" ? "row" : "column")}; + width: ${({ direction, size }) => (direction === "horizontal" ? null : `${size}px`)}; + height: ${({ direction, size }) => (direction === "vertical" ? null : `${size}px`)}; + flex-shrink: 0; +`; + +const Wrapper = styled.div` + width: calc(100% - 4px); + height: 100%; + background: ${props => props.theme.main.deepestBg}; +`; + +const Gutter = styled.div` + background: #000000; + user-select: none; + background: ${props => props.theme.main.deepestBg}; +`; + +const HorizontalGutter = styled(Gutter)` + height: 4px; + cursor: row-resize; +`; + +const VerticalGutter = styled(Gutter)` + width: 4px; + cursor: col-resize; +`; + +export default Resizable; diff --git a/web/src/beta/features/Editor/index.tsx b/web/src/beta/features/Editor/index.tsx new file mode 100644 index 0000000000..8df739f2a4 --- /dev/null +++ b/web/src/beta/features/Editor/index.tsx @@ -0,0 +1,86 @@ +import Resizable from "@reearth/beta/components/Resizable"; +import useLeftPanel from "@reearth/beta/features/Editor/useLeftPanel"; +import useRightPanel from "@reearth/beta/features/Editor/useRightPanel"; +import useVisualizerNav from "@reearth/beta/features/Editor/useVisualizerNav"; +import Navbar, { Tab } from "@reearth/beta/features/Navbar"; +import Visualizer from "@reearth/beta/features/Visualizer"; +import { Provider as DndProvider } from "@reearth/beta/utils/use-dnd"; +import { metrics, styled } from "@reearth/services/theme"; + +type Props = { + sceneId: string; + tab: Tab; +}; + +const Editor: React.FC = ({ sceneId, tab }) => { + const { leftPanel } = useLeftPanel({ tab }); + const { rightPanel } = useRightPanel({ tab }); + const { visualizerNav } = useVisualizerNav({ tab }); + + return ( + + + + + {leftPanel && ( + + {leftPanel} + + )} +
+ + {visualizerNav &&
{visualizerNav}
} + +
+
+ {rightPanel && ( + + {rightPanel} + + )} +
+
+
+ ); +}; + +export default Editor; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + color: ${({ theme }) => theme.main.text}; +`; + +const MainSection = styled.div` + display: flex; + flex-grow: 1; + height: 100%; + background-color: ${({ theme }) => theme.main.deepestBg}; +`; + +const Center = styled.div` + height: 100%; + flex-grow: 1; + display: flex; + flex-direction: column; +`; + +const VisualizerWrapper = styled.div<{ hasNav: boolean }>` + ${({ hasNav, theme }) => hasNav && `border: 1px solid ${theme.main.deepBg}`}; + height: 100%; + border-radius: 4px; + flex-grow: 1; +`; diff --git a/web/src/beta/features/Editor/useLeftPanel.tsx b/web/src/beta/features/Editor/useLeftPanel.tsx new file mode 100644 index 0000000000..77b9700ecd --- /dev/null +++ b/web/src/beta/features/Editor/useLeftPanel.tsx @@ -0,0 +1,59 @@ +import { ReactNode, useMemo } from "react"; +import SidePanel, { SidePanelContent } from "src/beta/features/SidePanel"; + +import { Tab } from "@reearth/beta/features/Navbar"; + +type Props = { + tab: Tab; +}; + +const getSceneContents = (): SidePanelContent[] => { + return [ + { + id: "Outline", + title: "Outline", + children: ( + <> + {[...Array(100)].map((_, i) => ( +
scrollable / {i}
+ ))} + + ), + }, + ]; +}; + +const getStoryContents = (): SidePanelContent[] => { + return [ + { + id: "Inspector", + title: "Inspector", + children: ( + <> + {[...Array(100)].map((_, i) => ( +
scrollable / {i}
+ ))} + + ), + }, + ]; +}; + +export default ({ tab }: Props) => { + const leftPanel = useMemo(() => { + switch (tab) { + case "scene": + return ; + case "story": + return ; + case "widgets": + case "publish": + default: + return undefined; + } + }, [tab]); + + return { + leftPanel, + }; +}; diff --git a/web/src/beta/features/Editor/useRightPanel.tsx b/web/src/beta/features/Editor/useRightPanel.tsx new file mode 100644 index 0000000000..73b8f53254 --- /dev/null +++ b/web/src/beta/features/Editor/useRightPanel.tsx @@ -0,0 +1,88 @@ +import { ReactNode, useMemo } from "react"; + +import { Tab } from "@reearth/beta/features/Navbar"; +import SidePanel, { SidePanelContent } from "@reearth/beta/features/SidePanel"; + +type Props = { + tab: Tab; +}; + +const getSceneContents = (): SidePanelContent[] => { + return [ + { + id: "Outline", + title: "Outline", + children: ( + <> + {[...Array(100)].map((_, i) => ( +
scrollable / {i}
+ ))} + + ), + }, + ]; +}; +const getStoryContents = (): SidePanelContent[] => { + return [ + { + id: "Outline", + title: "Outline", + children: ( + <> + {[...Array(100)].map((_, i) => ( +
scrollable / {i}
+ ))} + + ), + }, + ]; +}; + +const getWidgetContents = (): SidePanelContent[] => { + return [ + { + id: "Widget Manager", + title: "Widget Manager", + children: ( + <> + {[...Array(100)].map((_, i) => ( +
scrollable / {i}
+ ))} + + ), + }, + { + id: "Widget Setting", + title: "Widget Setting", + maxHeight: "30%", + children: ( + <> + {[...Array(100)].map((_, i) => ( +
scrollable / {i}
+ ))} + + ), + }, + ]; +}; + +export default ({ tab }: Props) => { + const rightPanel = useMemo(() => { + switch (tab) { + case "scene": + return ; + case "story": + return ; + case "widgets": + return ; + + case "publish": + default: + return undefined; + } + }, [tab]); + + return { + rightPanel, + }; +}; diff --git a/web/src/beta/features/Editor/useVisualizerNav.tsx b/web/src/beta/features/Editor/useVisualizerNav.tsx new file mode 100644 index 0000000000..b9096fd3b2 --- /dev/null +++ b/web/src/beta/features/Editor/useVisualizerNav.tsx @@ -0,0 +1,27 @@ +import { ReactNode, useMemo } from "react"; + +import { Tab } from "@reearth/beta/features/Navbar"; +import VisualizerNav from "@reearth/beta/features/VisualizerNav"; + +type Props = { + tab: Tab; +}; + +export default ({ tab }: Props) => { + const visualizerNav = useMemo(() => { + switch (tab) { + case "widgets": + return ; + case "publish": + return ; + case "scene": + case "story": + default: + return undefined; + } + }, [tab]); + + return { + visualizerNav, + }; +}; diff --git a/web/src/beta/features/LeftPanel/index.tsx b/web/src/beta/features/LeftPanel/index.tsx deleted file mode 100644 index 42539d3a7a..0000000000 --- a/web/src/beta/features/LeftPanel/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { styled } from "@reearth/services/theme"; - -const LeftPanel: React.FC = () => { - return LeftPanel; -}; - -export default LeftPanel; - -const Wrapper = styled.div` - background: #232226; - width: 235px; -`; diff --git a/web/src/beta/features/Navbar/index.tsx b/web/src/beta/features/Navbar/index.tsx index 64d88788a8..5c7ea9f6bc 100644 --- a/web/src/beta/features/Navbar/index.tsx +++ b/web/src/beta/features/Navbar/index.tsx @@ -15,7 +15,6 @@ export function isTab(tab: string): tab is Tab { const Navbar: React.FC = ({ sceneId, currentTab }) => { const handleEditorNavigation = useEditorNavigation({ sceneId }); - return (

Navbar

@@ -55,5 +54,5 @@ const Wrapper = styled.div` display: flex; justify-content: space-between; height: 53px; - background: #171618; + background: ${({ theme }) => theme.editorNavBar.bg}; `; diff --git a/web/src/beta/features/RightPanel/index.tsx b/web/src/beta/features/RightPanel/index.tsx deleted file mode 100644 index 45f86b0224..0000000000 --- a/web/src/beta/features/RightPanel/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { styled } from "@reearth/services/theme"; - -const RightPanel: React.FC = () => { - return RightPanel; -}; - -export default RightPanel; - -const Wrapper = styled.div` - background: #232226; - width: 235px; -`; diff --git a/web/src/beta/features/SidePanel/index.tsx b/web/src/beta/features/SidePanel/index.tsx new file mode 100644 index 0000000000..4324a43371 --- /dev/null +++ b/web/src/beta/features/SidePanel/index.tsx @@ -0,0 +1,81 @@ +import React, { CSSProperties, ReactNode } from "react"; + +import { styled } from "@reearth/services/theme"; + +export type SidePanelContent = { + id: string; + title: ReactNode; + children: ReactNode; + maxHeight?: CSSProperties["maxHeight"]; +}; +type Props = { + location: "left" | "right"; + contents: SidePanelContent[]; +}; + +const SidePanel: React.FC = ({ location, contents }) => { + return ( + + {contents.map(content => ( + + + {content.title} + {content.children} + + + ))} + + ); +}; + +export default SidePanel; + +const Wrapper = styled.div>` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + box-sizing: border-box; + gap: 4px; + padding: 4px; + + // for Resizable gutter width + ${({ location }) => location === "left" && `padding-right: 0;`} + ${({ location }) => location === "right" && `padding-left: 0;`} +`; + +const Item = styled.div<{ maxHeight?: CSSProperties["maxHeight"] }>` + flex-grow: 1; + height: 100%; + ${({ maxHeight }) => maxHeight && `max-height: ${maxHeight};`} +`; + +const Card = styled.div` + background: ${({ theme }) => theme.leftMenu.bg}; + border-radius: 4px; + height: 100%; + display: flex; + flex-direction: column; +`; + +const CardTitle = styled.div` + background: ${({ theme }) => theme.leftMenu.bgTitle}; + padding: 8px; + font-weight: 500; + font-size: 12px; + line-height: 1.34; + border-top-right-radius: 4px; + border-top-left-radius: 4px; +`; + +const CardContent = styled.div` + padding: 8px; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + overflow-y: auto; + flex-grow: 1; + height: 0; + ::-webkit-scrollbar { + display: none; + } +`; diff --git a/web/src/beta/features/Visualizer/index.tsx b/web/src/beta/features/Visualizer/index.tsx index c16175077e..1fbf000bbc 100644 --- a/web/src/beta/features/Visualizer/index.tsx +++ b/web/src/beta/features/Visualizer/index.tsx @@ -13,10 +13,6 @@ const Visualizer: React.FC = () => { export default Visualizer; const Wrapper = styled.div` - background: #3f3d45; - display: flex; - flex-flow: column; - flex: auto; - width: 100%; + background: ${({ theme }) => theme.main.bg}; height: 100%; `; diff --git a/web/src/beta/features/VisualizerNav/index.tsx b/web/src/beta/features/VisualizerNav/index.tsx new file mode 100644 index 0000000000..7b8114d344 --- /dev/null +++ b/web/src/beta/features/VisualizerNav/index.tsx @@ -0,0 +1,13 @@ +import styled from "@emotion/styled"; + +type Props = {}; + +const VisualizerNav: React.FC = () => { + return VisualizerNav; +}; + +export default VisualizerNav; + +const Wrapper = styled.div` + height: 48px; // dummy +`; diff --git a/web/src/beta/pages/Editor/index.tsx b/web/src/beta/pages/Editor/index.tsx deleted file mode 100644 index 30626d5e42..0000000000 --- a/web/src/beta/pages/Editor/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useParams } from "react-router-dom"; - -import NotFound from "@reearth/beta/components/NotFound"; -import LeftPanel from "@reearth/beta/features/LeftPanel"; -import Navbar, { isTab } from "@reearth/beta/features/Navbar"; -import RightPanel from "@reearth/beta/features/RightPanel"; -import Visualizer from "@reearth/beta/features/Visualizer"; -import { Provider as DndProvider } from "@reearth/beta/utils/use-dnd"; -import { styled } from "@reearth/services/theme"; - -type Props = {}; - -const Editor: React.FC = () => { - const { sceneId, tab } = useParams<{ sceneId: string; tab: string }>(); - - if (!sceneId || !tab || !isTab(tab)) { - return ; - } - - return ( - - - - - - - - - - - ); -}; - -export default Editor; - -const Wrapper = styled.div` - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -`; - -const MainSection = styled.div` - display: flex; - flex: 1; - height: 100%; - width: 100%; -`; diff --git a/web/src/beta/pages/EditorPage/index.tsx b/web/src/beta/pages/EditorPage/index.tsx new file mode 100644 index 0000000000..f2572ce816 --- /dev/null +++ b/web/src/beta/pages/EditorPage/index.tsx @@ -0,0 +1,15 @@ +import { useParams } from "react-router-dom"; + +import NotFound from "@reearth/beta/components/NotFound"; +import Editor from "@reearth/beta/features/Editor"; +import { isTab } from "@reearth/beta/features/Navbar"; + +type Props = {}; + +const EditorPage: React.FC = () => { + const { sceneId, tab } = useParams<{ sceneId: string; tab: string }>(); + + return !sceneId || !tab || !isTab(tab) ? : ; +}; + +export default EditorPage; diff --git a/web/src/classic/components/molecules/EarthEditor/EarthEditorPage/index.tsx b/web/src/classic/components/molecules/EarthEditor/EarthEditorPage/index.tsx index 6d38cd97d8..bfac1e5b98 100644 --- a/web/src/classic/components/molecules/EarthEditor/EarthEditorPage/index.tsx +++ b/web/src/classic/components/molecules/EarthEditor/EarthEditorPage/index.tsx @@ -33,9 +33,9 @@ const EarthEditorPage: React.FC = ({ + size={metrics.propertyMenuMinWidthClassic} + minSize={metrics.propertyMenuMinWidthClassic} + maxSize={metrics.propertyMenuMaxWidthClassic}> {left}
@@ -45,9 +45,9 @@ const EarthEditorPage: React.FC = ({ + size={metrics.propertyMenuMinWidthClassic} + minSize={metrics.propertyMenuMinWidthClassic} + maxSize={metrics.propertyMenuMaxWidthClassic}> {right} diff --git a/web/src/services/routing/index.tsx b/web/src/services/routing/index.tsx index aa9fa409e8..478cc1a59c 100644 --- a/web/src/services/routing/index.tsx +++ b/web/src/services/routing/index.tsx @@ -19,7 +19,7 @@ import { styled } from "@reearth/services/theme"; import RootPage from "../../classic/components/pages/Authentication/RootPage"; import Preview from "../../classic/components/pages/Preview"; -const BetaEditor = lazy(() => import("@reearth/beta/pages/Editor")); +const BetaEditor = lazy(() => import("@reearth/beta/pages/EditorPage")); const EarthEditor = lazy(() => import("@reearth/classic/components/pages/EarthEditor")); const Dashboard = lazy(() => import("@reearth/classic/components/pages/Dashboard")); diff --git a/web/src/services/theme/darkTheme.ts b/web/src/services/theme/darkTheme.ts index 7b2aac86ea..8b4779ba3b 100644 --- a/web/src/services/theme/darkTheme.ts +++ b/web/src/services/theme/darkTheme.ts @@ -70,8 +70,12 @@ const darkTheme: Theme = { published: colors.dark.functional.success, unpublished: colors.dark.outline.main, }, + editorNavBar: { + bg: colors.dark.bg[2], + }, leftMenu: { bg: colors.dark.bg[2], + bgTitle: colors.dark.bg[5], hoverBg: colors.dark.bg[1], highlighted: colors.dark.functional.select, icon: colors.dark.functional.select, diff --git a/web/src/services/theme/lightheme.ts b/web/src/services/theme/lightheme.ts index 6dace3aaa4..805ee372e8 100644 --- a/web/src/services/theme/lightheme.ts +++ b/web/src/services/theme/lightheme.ts @@ -70,8 +70,12 @@ const lightheme: Theme = { published: colors.light.functional.success, unpublished: colors.light.outline.main, }, + editorNavBar: { + bg: colors.light.bg[2], + }, leftMenu: { bg: colors.light.bg[2], + bgTitle: colors.light.bg[5], hoverBg: colors.light.bg[1], highlighted: colors.light.functional.select, icon: colors.light.functional.select, diff --git a/web/src/services/theme/metrics.ts b/web/src/services/theme/metrics.ts index 14a5420cf7..80a9908683 100644 --- a/web/src/services/theme/metrics.ts +++ b/web/src/services/theme/metrics.ts @@ -4,8 +4,10 @@ const metrics = { MenuIconBarWidth: 40, layerIconAndArrowWidth: 46, layerSidePadding: 10, - propertyMenuMinWidth: 264, - propertyMenuMaxWidth: 346, + propertyMenuMinWidthClassic: 264, // REMOVE WITH CLASSIC + propertyMenuMaxWidthClassic: 346, // REMOVE WITH CLASSIC + propertyMenuMinWidth: 272, + propertyMenuMaxWidth: 336, propertyTextInputHeight: 30, propertyTextareaHeight: 190, dashboardWorkspaceMinWidth: 364, diff --git a/web/src/services/theme/theme.ts b/web/src/services/theme/theme.ts index a703bd62b9..8ca4c624ce 100644 --- a/web/src/services/theme/theme.ts +++ b/web/src/services/theme/theme.ts @@ -72,8 +72,12 @@ export type Theme = { published: string; unpublished: string; }; + editorNavBar: { + bg: string; + }; leftMenu: { bg: string; + bgTitle: string; highlighted: string; hoverBg: string; icon: string;