diff --git a/web/src/beta/features/Editor/StoryPanel/Page/index.tsx b/web/src/beta/features/Editor/StoryPanel/Page/index.tsx index ead759a7b4..422d68ddd2 100644 --- a/web/src/beta/features/Editor/StoryPanel/Page/index.tsx +++ b/web/src/beta/features/Editor/StoryPanel/Page/index.tsx @@ -1,10 +1,13 @@ -import { Fragment } from "react"; +import { Fragment, useMemo } from "react"; -import { Item } from "@reearth/services/api/propertyApi/utils"; +import { convert } from "@reearth/services/api/propertyApi/utils"; import { InstallableStoryBlock } from "@reearth/services/api/storytellingApi/blocks"; +import { useT } from "@reearth/services/i18n"; import { styled } from "@reearth/services/theme"; import StoryBlock from "../Block"; +import { StoryPageFragmentFragment } from "../hooks"; +import SelectableArea from "../SelectableArea"; import BlockAddBar from "./BlockAddBar"; import useHooks from "./hooks"; @@ -12,24 +15,31 @@ import useHooks from "./hooks"; type Props = { sceneId?: string; storyId?: string; - pageId?: string; - propertyId?: string; - propertyItems?: Item[]; + page?: StoryPageFragmentFragment; + selectedPageId?: string; installableStoryBlocks?: InstallableStoryBlock[]; selectedStoryBlockId?: string; + showPageSettings?: boolean; + onPageSettingsToggle?: () => void; + onPageSelect?: (pageId?: string | undefined) => void; onBlockSelect: (blockId?: string) => void; }; const StoryPage: React.FC = ({ sceneId, storyId, - pageId, - propertyId, - propertyItems, + page, + selectedPageId, installableStoryBlocks, selectedStoryBlockId, + showPageSettings, + onPageSettingsToggle, + onPageSelect, onBlockSelect, }) => { + const t = useT(); + const propertyItems = useMemo(() => convert(page?.property), [page?.property]); + const { openBlocksIndex, installedStoryBlocks, @@ -42,57 +52,69 @@ const StoryPage: React.FC = ({ } = useHooks({ sceneId, storyId, - pageId, + pageId: page?.id, propertyItems, }); return ( - - {titleProperty && ( - onBlockSelect(titleId)} - onClickAway={onBlockSelect} - onChange={handlePropertyValueUpdate} + onPageSelect?.(page?.id)} + showSettings={showPageSettings} + onSettingsToggle={onPageSettingsToggle}> + + {titleProperty && ( + onBlockSelect(titleId)} + onClickAway={onBlockSelect} + onChange={handlePropertyValueUpdate} + /> + )} + handleBlockOpen(-1)} + onBlockAdd={handleStoryBlockCreate(0)} /> - )} - handleBlockOpen(-1)} - onBlockAdd={handleStoryBlockCreate(0)} - /> - {installedStoryBlocks && - installedStoryBlocks.length > 0 && - installedStoryBlocks.map((b, idx) => ( - - onBlockSelect(b.id)} - onClickAway={onBlockSelect} - onChange={handlePropertyValueUpdate} - onRemove={handleStoryBlockDelete} - /> - handleBlockOpen(idx)} - onBlockAdd={handleStoryBlockCreate(idx + 1)} - /> - - ))} - + {installedStoryBlocks && + installedStoryBlocks.length > 0 && + installedStoryBlocks.map((b, idx) => ( + + onBlockSelect(b.id)} + onClickAway={onBlockSelect} + onChange={handlePropertyValueUpdate} + onRemove={handleStoryBlockDelete} + /> + handleBlockOpen(idx)} + onBlockAdd={handleStoryBlockCreate(idx + 1)} + /> + + ))} + + ); }; diff --git a/web/src/beta/features/Editor/StoryPanel/PanelContent/index.tsx b/web/src/beta/features/Editor/StoryPanel/PanelContent/index.tsx new file mode 100644 index 0000000000..7d9fc9dd6d --- /dev/null +++ b/web/src/beta/features/Editor/StoryPanel/PanelContent/index.tsx @@ -0,0 +1,145 @@ +import { Fragment, useEffect, useMemo, useRef } from "react"; + +import { InstallableStoryBlock } from "@reearth/services/api/storytellingApi/blocks"; +import { styled } from "@reearth/services/theme"; + +import { StoryPageFragmentFragment } from "../hooks"; +import StoryPage from "../Page"; + +export const pagesElementId = "story-page-content"; + +export type Props = { + sceneId?: string; + storyId?: string; + pages?: StoryPageFragmentFragment[]; + selectedPageId?: string; + installableStoryBlocks?: InstallableStoryBlock[]; + selectedStoryBlockId?: string; + showPageSettings?: boolean; + showingIndicator?: boolean; + isAutoScrolling?: boolean; + onAutoScrollingChange: (isScrolling: boolean) => void; + onPageSettingsToggle?: () => void; + onPageSelect?: (pageId?: string | undefined) => void; + onBlockSelect: (blockId?: string) => void; + onCurrentPageChange?: (pageId: string) => void; +}; + +const StoryContent: React.FC = ({ + sceneId, + storyId, + pages, + selectedPageId, + installableStoryBlocks, + selectedStoryBlockId, + showPageSettings, + showingIndicator, + isAutoScrolling, + onAutoScrollingChange, + onPageSettingsToggle, + onPageSelect, + onBlockSelect, + onCurrentPageChange, +}) => { + const scrollRef = useRef(undefined); + const scrollTimeoutRef = useRef(); + + const pageHeight = useMemo(() => { + const element = document.getElementById(pagesElementId); + return element?.clientHeight; + }, []); + + useEffect(() => { + const ids = pages?.map(p => p.id) as string[]; + const panelContentElement = document.getElementById(pagesElementId); + + const observer = new IntersectionObserver( + entries => { + if (isAutoScrolling) return; // to avoid conflicts with page selection in editor UI + entries.forEach(entry => { + const id = entry.target.getAttribute("id") ?? ""; + if (selectedPageId === id) return; + + const diff = (scrollRef.current as number) - (panelContentElement?.scrollTop as number); + const isScrollingUp = diff > 0; + + if (entry.isIntersecting) { + onCurrentPageChange?.(id); + scrollRef.current = panelContentElement?.scrollTop; + return; + } + const currentIndex = ids?.indexOf(id) as number; + const prevEntry = ids[currentIndex - 1]; + if (isScrollingUp) { + const id = prevEntry; + onCurrentPageChange?.(id); + } + }); + }, + { + root: panelContentElement, + threshold: 0.2, + }, + ); + ids?.forEach(id => { + const e = document.getElementById(id); + if (e) { + observer.observe(e); + } + }); + return () => { + ids?.forEach(id => { + const e = document.getElementById(id); + if (e) { + observer.unobserve(e); + } + }); + }; + }, [pages, selectedPageId, isAutoScrolling, onCurrentPageChange]); + + useEffect(() => { + const wrapperElement = document.getElementById(pagesElementId); + if (isAutoScrolling) { + wrapperElement?.addEventListener("scroll", () => { + clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = setTimeout(function () { + onAutoScrollingChange(false); + }, 100); + }); + } + }, [isAutoScrolling, onAutoScrollingChange]); + + return ( + + {pages?.map(p => ( + + + + + ))} + + ); +}; + +export default StoryContent; + +const PagesWrapper = styled.div<{ showingIndicator?: boolean }>` + height: ${({ showingIndicator }) => (showingIndicator ? "calc(100% - 8px)" : "100%")}; + overflow-y: auto; + cursor: pointer; +`; + +const PageGap = styled.div<{ height?: number }>` + height: ${({ height }) => (height ? height + "px" : "70vh")}; +`; diff --git a/web/src/beta/features/Editor/StoryPanel/hooks.ts b/web/src/beta/features/Editor/StoryPanel/hooks.ts index e8e753e260..8bab9919e1 100644 --- a/web/src/beta/features/Editor/StoryPanel/hooks.ts +++ b/web/src/beta/features/Editor/StoryPanel/hooks.ts @@ -1,22 +1,20 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import useStorytellingAPI from "@reearth/services/api/storytellingApi"; import type { StoryFragmentFragment, StoryPageFragmentFragment } from "@reearth/services/gql"; export type { StoryFragmentFragment, StoryPageFragmentFragment } from "@reearth/services/gql"; -export const pageElementId = "story-page"; - export default ({ sceneId, selectedStory, currentPage, - onPageSelect, + onCurrentPageChange, }: { sceneId?: string; selectedStory?: StoryFragmentFragment; currentPage?: StoryPageFragmentFragment; - onPageSelect: (id: string) => void; + onCurrentPageChange: (id: string, disableScrollIntoView?: boolean) => void; }) => { const [showPageSettings, setShowPageSettings] = useState(false); const [selectedPageId, setSelectedPageId] = useState(); @@ -51,11 +49,12 @@ export default ({ const { installableStoryBlocks } = useInstallableStoryBlocksQuery({ sceneId }); - useEffect(() => { - if (currentPage) { - document.getElementById(currentPage.id)?.scrollIntoView({ behavior: "smooth" }); - } - }, [currentPage]); + const handleCurrentPageChange = useCallback( + (pageId: string) => { + onCurrentPageChange(pageId, true); // true disables scrollIntoView + }, + [onCurrentPageChange], + ); const pageInfo = useMemo(() => { const pages = selectedStory?.pages ?? []; @@ -65,18 +64,12 @@ export default ({ return { currentPage: currentIndex + 1, maxPage: pages.length, - onPageChange: (page: number) => onPageSelect(pages[page - 1]?.id), + onPageChange: (pageIndex: number) => onCurrentPageChange(pages[pageIndex - 1]?.id), }; - }, [onPageSelect, currentPage, selectedStory]); - - const pageHeight = useMemo(() => { - const element = document.getElementById(pageElementId); - return element?.clientHeight; - }, []); + }, [selectedStory, currentPage, onCurrentPageChange]); return { pageInfo, - pageHeight, installableBlocks: installableStoryBlocks, selectedPageId, selectedBlockId, @@ -84,5 +77,6 @@ export default ({ handlePageSettingsToggle, handlePageSelect, handleBlockSelect, + handleCurrentPageChange, }; }; diff --git a/web/src/beta/features/Editor/StoryPanel/index.tsx b/web/src/beta/features/Editor/StoryPanel/index.tsx index 778244a734..3e27cf58ee 100644 --- a/web/src/beta/features/Editor/StoryPanel/index.tsx +++ b/web/src/beta/features/Editor/StoryPanel/index.tsx @@ -1,30 +1,32 @@ -import { FC, Fragment } from "react"; +import { FC } from "react"; import PageIndicator from "@reearth/beta/features/Editor/StoryPanel/PageIndicator"; -import { convert } from "@reearth/services/api/propertyApi/utils"; import { styled } from "@reearth/services/theme"; -import useHooks, { - pageElementId, - type StoryFragmentFragment, - type StoryPageFragmentFragment, -} from "./hooks"; -import StoryPage from "./Page"; -import SelectableArea from "./SelectableArea"; +import useHooks, { type StoryFragmentFragment, type StoryPageFragmentFragment } from "./hooks"; +import StoryContent from "./PanelContent"; export const storyPanelWidth = 442; -type Props = { +export type Props = { sceneId?: string; selectedStory?: StoryFragmentFragment; currentPage?: StoryPageFragmentFragment; - onPageSelect: (id: string) => void; + isAutoScrolling?: boolean; + onAutoScrollingChange: (isScrolling: boolean) => void; + onCurrentPageChange: (id: string, disableScrollIntoView?: boolean) => void; }; -export const StoryPanel: FC = ({ sceneId, selectedStory, currentPage, onPageSelect }) => { +export const StoryPanel: FC = ({ + sceneId, + selectedStory, + currentPage, + isAutoScrolling, + onAutoScrollingChange, + onCurrentPageChange, +}) => { const { pageInfo, - pageHeight, installableBlocks, selectedPageId, selectedBlockId, @@ -32,15 +34,16 @@ export const StoryPanel: FC = ({ sceneId, selectedStory, currentPage, onP handlePageSettingsToggle, handlePageSelect, handleBlockSelect, + handleCurrentPageChange, } = useHooks({ sceneId, selectedStory, currentPage, - onPageSelect, + onCurrentPageChange, }); return ( - + {!!pageInfo && ( = ({ sceneId, selectedStory, currentPage, onP onPageChange={pageInfo.onPageChange} /> )} - - {selectedStory?.pages.map(p => { - const propertyItems = convert(p.property); - return ( - - handlePageSelect(p.id)} - showSettings={showPageSettings} - onSettingsToggle={handlePageSettingsToggle}> - - - - - ); - })} - - + + ); }; export default StoryPanel; -const Wrapper = styled.div` +const PanelWrapper = styled.div` flex: 0 0 ${storyPanelWidth}px; background: #f1f1f1; color: ${({ theme }) => theme.content.weak}; `; - -const PageWrapper = styled.div<{ showingIndicator?: boolean }>` - height: ${({ showingIndicator }) => (showingIndicator ? "calc(100% - 8px)" : "100%")}; - overflow-y: auto; - cursor: pointer; -`; - -const PageGap = styled.div<{ height?: number }>` - height: ${({ height }) => (height ? height + "px" : "70vh")}; -`; diff --git a/web/src/beta/features/Editor/Visualizer/CanvasArea/hooks.ts b/web/src/beta/features/Editor/Visualizer/CanvasArea/hooks.ts index 4c68e16640..0a673b1e05 100644 --- a/web/src/beta/features/Editor/Visualizer/CanvasArea/hooks.ts +++ b/web/src/beta/features/Editor/Visualizer/CanvasArea/hooks.ts @@ -186,8 +186,6 @@ export default ({ sceneId, isBuilt }: { sceneId?: string; isBuilt?: boolean }) = return !!sceneProperty?.experimental?.experimental_sandbox; }, [sceneProperty]); - console.log("layers: ", layers); - return { sceneId, rootLayerId, diff --git a/web/src/beta/features/Editor/index.tsx b/web/src/beta/features/Editor/index.tsx index ce32e32d5b..04e6b97f0a 100644 --- a/web/src/beta/features/Editor/index.tsx +++ b/web/src/beta/features/Editor/index.tsx @@ -48,8 +48,10 @@ const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories const { selectedStory, - selectedPage, - handlePageSelect, + currentPage, + isAutoScrolling, + handleAutoScrollingChange, + handleCurrentPageChange, handlePageDuplicate, handlePageDelete, handlePageAdd, @@ -69,9 +71,9 @@ const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories sceneId, nlsLayers, selectedStory, - selectedPage, selectedLayer, - onPageSelect: handlePageSelect, + currentPage, + onCurrentPageChange: handleCurrentPageChange, onPageDuplicate: handlePageDuplicate, onPageDelete: handlePageDelete, onPageAdd: handlePageAdd, @@ -81,7 +83,7 @@ const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories onDataSourceManagerOpen: handleDataSourceManagerOpener, }); - const { rightPanel } = useRightPanel({ tab, sceneId, selectedPage }); + const { rightPanel } = useRightPanel({ tab, sceneId, currentPage }); const { secondaryNavbar } = useSecondaryNavbar({ tab, @@ -124,8 +126,10 @@ const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories )} diff --git a/web/src/beta/features/Editor/useLeftPanel.tsx b/web/src/beta/features/Editor/useLeftPanel.tsx index 7a2ad21eb0..0e20da8be5 100644 --- a/web/src/beta/features/Editor/useLeftPanel.tsx +++ b/web/src/beta/features/Editor/useLeftPanel.tsx @@ -13,8 +13,8 @@ type Props = { // for story tab selectedStory?: StoryFragmentFragment; - selectedPage?: StoryPageFragmentFragment; - onPageSelect: (id: string) => void; + currentPage?: StoryPageFragmentFragment; + onCurrentPageChange: (id: string) => void; onPageDuplicate: (id: string) => void; onPageDelete: (id: string) => void; onPageAdd: (isSwipeable: boolean) => void; @@ -32,8 +32,8 @@ export default ({ sceneId, nlsLayers, selectedStory, - selectedPage, - onPageSelect, + currentPage, + onCurrentPageChange, onPageDuplicate, onPageDelete, onPageAdd, @@ -58,8 +58,8 @@ export default ({ return ( { +export default ({ tab, sceneId, currentPage }: Props) => { const rightPanel = useMemo(() => { switch (tab) { case "map": return ; case "story": - return ; + return ; case "widgets": return ; @@ -27,7 +27,7 @@ export default ({ tab, sceneId, selectedPage }: Props) => { default: return undefined; } - }, [tab, sceneId, selectedPage]); + }, [tab, sceneId, currentPage]); return { rightPanel, diff --git a/web/src/beta/features/Editor/useStorytelling.ts b/web/src/beta/features/Editor/useStorytelling.ts index 5b5562a79d..b2b71b0fdd 100644 --- a/web/src/beta/features/Editor/useStorytelling.ts +++ b/web/src/beta/features/Editor/useStorytelling.ts @@ -1,33 +1,55 @@ import { useCallback, useMemo, useState } from "react"; import useStorytellingAPI from "@reearth/services/api/storytellingApi"; -import { StoryFragmentFragment } from "@reearth/services/gql"; +import { StoryFragmentFragment, StoryPageFragmentFragment } from "@reearth/services/gql"; import { useT } from "@reearth/services/i18n"; type Props = { sceneId: string; stories: StoryFragmentFragment[]; }; + +const getPage = (id?: string, pages?: StoryPageFragmentFragment[]) => { + if (!id || !pages || !pages.length) return; + return pages.find(p => p.id === id); +}; + export default function ({ sceneId, stories }: Props) { const t = useT(); const { useCreateStoryPage, useDeleteStoryPage, useMoveStoryPage } = useStorytellingAPI(); - const [selectedPageId, setSelectedPageId] = useState(undefined); + const [currentPageId, setCurrentPageId] = useState(undefined); + const [isAutoScrolling, setAutoScrolling] = useState(false); const selectedStory = useMemo(() => { return stories.length ? stories[0] : undefined; }, [stories]); - const selectedPage = useMemo(() => { - if (!selectedPageId && selectedStory?.pages?.length) { + const currentPage = useMemo(() => { + if (!currentPageId && selectedStory?.pages?.length) { return selectedStory?.pages[0]; } - return (selectedStory?.pages ?? []).find(p => p.id === selectedPageId); - }, [selectedPageId, selectedStory?.pages]); + return getPage(currentPageId, selectedStory?.pages); + }, [currentPageId, selectedStory?.pages]); - const handlePageSelect = useCallback((pageId: string) => { - setSelectedPageId(pageId); - }, []); + const handleAutoScrollingChange = useCallback( + (isScrolling: boolean) => setAutoScrolling(isScrolling), + [], + ); + + const handleCurrentPageChange = useCallback( + (pageId: string, disableScrollIntoView?: boolean) => { + const newPage = getPage(pageId, selectedStory?.pages); + if (!newPage) return; + setCurrentPageId(pageId); + if (!disableScrollIntoView) { + const element = document.getElementById(newPage.id); + setAutoScrolling(true); + element?.scrollIntoView({ behavior: "smooth" }); + } + }, + [selectedStory?.pages], + ); const handlePageDuplicate = useCallback(async (pageId: string) => { console.log("onPageDuplicate", pageId); @@ -45,11 +67,11 @@ export default function ({ sceneId, stories }: Props) { storyId: selectedStory.id, pageId, }); - if (pageId === selectedPageId) { - setSelectedPageId(pages[deletedPageIndex + 1]?.id ?? pages[deletedPageIndex - 1]?.id); + if (pageId === currentPageId) { + setCurrentPageId(pages[deletedPageIndex + 1]?.id ?? pages[deletedPageIndex - 1]?.id); } }, - [useDeleteStoryPage, sceneId, selectedPageId, selectedStory], + [useDeleteStoryPage, sceneId, currentPageId, selectedStory], ); const handlePageAdd = useCallback( @@ -82,8 +104,10 @@ export default function ({ sceneId, stories }: Props) { return { selectedStory, - selectedPage, - handlePageSelect, + currentPage, + isAutoScrolling, + handleAutoScrollingChange, + handleCurrentPageChange, handlePageDuplicate, handlePageDelete, handlePageAdd,