diff --git a/packages/frontend/component/src/components/app-sidebar/menu-item/index.tsx b/packages/frontend/component/src/components/app-sidebar/menu-item/index.tsx index 48974f2784..14324ef8be 100644 --- a/packages/frontend/component/src/components/app-sidebar/menu-item/index.tsx +++ b/packages/frontend/component/src/components/app-sidebar/menu-item/index.tsx @@ -1,7 +1,7 @@ import { ArrowDownSmallIcon } from '@blocksuite/icons'; import clsx from 'clsx'; import React from 'react'; -import type { LinkProps } from 'react-router-dom'; +import type { To } from 'react-router-dom'; import { Link } from 'react-router-dom'; import * as styles from './index.css'; @@ -17,9 +17,10 @@ export interface MenuItemProps extends React.HTMLAttributes { postfix?: React.ReactElement; } -export interface MenuLinkItemProps - extends MenuItemProps, - Pick {} +export interface MenuLinkItemProps extends MenuItemProps { + to: To; + linkComponent?: React.ComponentType<{ to: To; className: string }>; +} const stopPropagation: React.MouseEventHandler = e => { e.stopPropagation(); @@ -89,13 +90,13 @@ export const MenuItem = React.forwardRef( MenuItem.displayName = 'MenuItem'; export const MenuLinkItem = React.forwardRef( - ({ to, ...props }, ref) => { + ({ to, linkComponent: LinkComponent = Link, ...props }, ref) => { return ( - + {/* The element rendered by Link does not generate display box due to `display: contents` style */} {/* Thus ref is passed to MenuItem instead of Link */} - + ); } ); diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index eca95bdc1a..960eaf53cb 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -63,6 +63,7 @@ "foxact": "^0.2.31", "fractional-indexing": "^3.2.0", "graphql": "^16.8.1", + "history": "^5.3.0", "idb": "^8.0.0", "image-blob-reduce": "^4.1.0", "intl-segmenter-polyfill-rs": "^0.1.7", diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx index 2c6a340723..de0cc65eaf 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx @@ -1,5 +1,5 @@ -import { Page, WorkspaceListService } from '@toeverything/infra'; -import { useService, useServiceOptional } from '@toeverything/infra/di'; +import { WorkspaceListService } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; @@ -16,7 +16,6 @@ export const DumpInfo = (_props: DumpInfoProps) => { const currentWorkspace = useLiveData( useService(CurrentWorkspaceService).currentWorkspace ); - const currentPage = useServiceOptional(Page); const path = location.pathname; const query = useParams(); useEffect(() => { @@ -24,9 +23,8 @@ export const DumpInfo = (_props: DumpInfoProps) => { path, query, currentWorkspaceId: currentWorkspace?.id, - currentPageId: currentPage?.id, workspaceList, }); - }, [path, query, currentWorkspace, workspaceList, currentPage?.id]); + }, [path, query, currentWorkspace, workspaceList]); return null; }; diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx index 2b02a63a63..d2801b57a1 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx @@ -2,8 +2,8 @@ import { Checkbox } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useDraggable } from '@dnd-kit/core'; import { type PropsWithChildren, useCallback, useMemo } from 'react'; -import { Link } from 'react-router-dom'; +import { WorkbenchLink } from '../../../modules/workbench/workbench-link'; import type { DraggableTitleCellData, PageListItemProps } from '../types'; import { ColWrapper, formatDate, stopPropagation } from '../utils'; import * as styles from './page-list-item.css'; @@ -235,14 +235,14 @@ function PageListItemWrapper({ 'data-dragging': isDragging, onClick: handleClick, }), - [pageId, draggable, isDragging, onClick, to, handleClick] + [pageId, draggable, onClick, to, isDragging, handleClick] ); if (to) { return ( - + {children} - + ); } else { return
{children}
; diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index 417e5c5ef6..b3f81948ef 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -321,10 +321,7 @@ function pageMetaToListItemProp( ), createDate: new Date(item.createDate), updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined, - to: - props.rowAsLink && !props.selectable - ? `/workspace/${props.blockSuiteWorkspace.id}/${item.id}` - : undefined, + to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined, onClick: props.selectable ? toggleSelection : undefined, icon: ( , @@ -407,10 +404,7 @@ function tagMetaToListItemProp( const itemProps: TagListItemProps = { tagId: item.id, title: item.title, - to: - props.rowAsLink && !props.selectable - ? `/workspace/${props.blockSuiteWorkspace.id}/tag/${item.id}` - : undefined, + to: props.rowAsLink && !props.selectable ? `/tag/${item.id}` : undefined, onClick: props.selectable ? toggleSelection : undefined, color: item.color, pageCount: item.pageCount, diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index 1bff5d5831..911e3ee03f 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -17,11 +17,12 @@ import * as Collapsible from '@radix-ui/react-collapsible'; import { useService } from '@toeverything/infra'; import { useLiveData } from '@toeverything/infra/livedata'; import { useCallback, useMemo, useState } from 'react'; -import { useLocation } from 'react-router-dom'; import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config'; import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; import { useBlockSuitePageMeta } from '../../../../hooks/use-block-suite-page-meta'; +import { Workbench } from '../../../../modules/workbench'; +import { WorkbenchLink } from '../../../../modules/workbench/workbench-link'; import type { CollectionsListProps } from '../index'; import { Page } from './page'; import * as styles from './styles.css'; @@ -88,9 +89,9 @@ const CollectionRenderer = ({ }; return filterPage(collection, pageData); }); - const location = useLocation(); - const currentPath = location.pathname.split('?')[0]; - const path = `/workspace/${workspace.id}/collection/${collection.id}`; + const location = useLiveData(useService(Workbench).location); + const currentPath = location.pathname; + const path = `/collection/${collection.id}`; const onRename = useCallback( (name: string) => { @@ -115,6 +116,7 @@ const CollectionRenderer = ({ active={isOver || currentPath === path} icon={} to={path} + linkComponent={WorkbenchLink} postfix={
void; createPage: () => Page; - currentPath: string; paths: { all: (workspaceId: string) => string; trash: (workspaceId: string) => string; @@ -98,7 +98,6 @@ export const RootAppSidebar = ({ currentWorkspace, openPage, createPage, - currentPath, paths, onOpenQuickSearchModal, onOpenSettingModal, @@ -111,6 +110,7 @@ export const RootAppSidebar = ({ openWorkspaceListModalAtom ); const generalShortcutsInfo = useGeneralShortcuts(); + const currentPath = useLiveData(useService(Workbench).location).pathname; const onClickNewPage = useAsyncCallback(async () => { const page = createPage(); @@ -193,21 +193,9 @@ export const RootAppSidebar = ({ }); }, [blockSuiteWorkspace.id, collection, navigateHelper, open]); - const allPageActive = useMemo(() => { - if ( - currentPath.startsWith(`/workspace/${currentWorkspaceId}/collection/`) || - currentPath.startsWith(`/workspace/${currentWorkspaceId}/tag/`) - ) { - return true; - } - return currentPath === paths.all(currentWorkspaceId); - }, [currentPath, currentWorkspaceId, paths]); + const allPageActive = currentPath === '/all'; - const trashActive = useMemo(() => { - return ( - currentPath === paths.trash(currentWorkspaceId) || trashDroppable.isOver - ); - }, [currentPath, currentWorkspaceId, paths, trashDroppable.isOver]); + const trashActive = currentPath === '/trash'; return ( { + console.error('Failed to navigate', err); + }); +} + // todo: add a name -> path helper in the results export function useNavigateHelper() { const location = useLocation(); - const navigate = useNavigate(); const jumpToPage = useCallback( ( @@ -27,7 +29,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToPageBlock = useCallback( ( @@ -40,7 +42,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToCollections = useCallback( (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { @@ -48,7 +50,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToTags = useCallback( (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { @@ -56,7 +58,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToTag = useCallback( ( @@ -68,7 +70,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToCollection = useCallback( ( @@ -80,7 +82,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToPublicWorkspacePage = useCallback( ( @@ -92,7 +94,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const jumpToSubPath = useCallback( ( @@ -104,7 +106,7 @@ export function useNavigateHelper() { replace: logic === RouteLogic.REPLACE, }); }, - [navigate] + [] ); const isPublicWorkspace = useMemo(() => { @@ -122,31 +124,22 @@ export function useNavigateHelper() { [jumpToPage, jumpToPublicWorkspacePage, isPublicWorkspace] ); - const jumpToIndex = useCallback( - (logic: RouteLogic = RouteLogic.PUSH) => { - return navigate('/', { - replace: logic === RouteLogic.REPLACE, - }); - }, - [navigate] - ); + const jumpToIndex = useCallback((logic: RouteLogic = RouteLogic.PUSH) => { + return navigate('/', { + replace: logic === RouteLogic.REPLACE, + }); + }, []); - const jumpTo404 = useCallback( - (logic: RouteLogic = RouteLogic.PUSH) => { - return navigate('/404', { - replace: logic === RouteLogic.REPLACE, - }); - }, - [navigate] - ); - const jumpToExpired = useCallback( - (logic: RouteLogic = RouteLogic.PUSH) => { - return navigate('/expired', { - replace: logic === RouteLogic.REPLACE, - }); - }, - [navigate] - ); + const jumpTo404 = useCallback((logic: RouteLogic = RouteLogic.PUSH) => { + return navigate('/404', { + replace: logic === RouteLogic.REPLACE, + }); + }, []); + const jumpToExpired = useCallback((logic: RouteLogic = RouteLogic.PUSH) => { + return navigate('/expired', { + replace: logic === RouteLogic.REPLACE, + }); + }, []); const jumpToSignIn = useCallback( ( logic: RouteLogic = RouteLogic.PUSH, @@ -157,7 +150,7 @@ export function useNavigateHelper() { ...otherOptions, }); }, - [navigate] + [] ); return useMemo( diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 83afa1f054..2e69fc47fd 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -20,7 +20,7 @@ import { useService } from '@toeverything/infra/di'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { PropsWithChildren, ReactNode } from 'react'; import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { Map as YMap } from 'yjs'; import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms'; @@ -151,7 +151,6 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { const handleDragEnd = useSidebarDrag(); const { appSettings } = useAppSettingHelper(); - const location = useLocation(); const { pageId } = useParams(); // todo: refactor this that the root layout do not need to check route state @@ -182,7 +181,6 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { [currentWorkspace, openPage] )} createPage={handleCreatePage} - currentPath={location.pathname.split('?')[0]} paths={pathGenerator} /> diff --git a/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx b/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx index ce31d11801..6207e2ee2a 100644 --- a/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx +++ b/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx @@ -1,16 +1,11 @@ -import type { Page } from '@toeverything/infra'; import { - LiveData, - ServiceCollection, type ServiceProvider, ServiceProviderContext, useLiveData, useService, - useServiceOptional, } from '@toeverything/infra'; import type React from 'react'; -import { CurrentPageService } from '../../page'; import { CurrentWorkspaceService } from '../../workspace'; export const GlobalScopeProvider: React.FC< @@ -24,18 +19,8 @@ export const GlobalScopeProvider: React.FC< currentWorkspaceService.currentWorkspace )?.services; - const currentPageService = useServiceOptional(CurrentPageService, { - provider: workspaceProvider ?? ServiceCollection.EMPTY.provider(), - }); - - const pageProvider = useLiveData( - currentPageService?.currentPage ?? new LiveData(null) - )?.services; - return ( - + {children} ); diff --git a/packages/frontend/core/src/modules/page/current-page.tsx b/packages/frontend/core/src/modules/page/current-page.tsx deleted file mode 100644 index 1e4a8bcef8..0000000000 --- a/packages/frontend/core/src/modules/page/current-page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Page } from '@toeverything/infra'; -import { LiveData } from '@toeverything/infra/livedata'; - -/** - * service to manage current page - */ -export class CurrentPageService { - currentPage = new LiveData(null); - - /** - * open page, current page will be set to the page - * @param page - */ - openPage(page: Page) { - this.currentPage.next(page); - } - - /** - * close current page, current page will be null - */ - closePage() { - this.currentPage.next(null); - } -} diff --git a/packages/frontend/core/src/modules/page/index.ts b/packages/frontend/core/src/modules/page/index.ts deleted file mode 100644 index 8875f2bc6e..0000000000 --- a/packages/frontend/core/src/modules/page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './current-page'; diff --git a/packages/frontend/core/src/modules/services.ts b/packages/frontend/core/src/modules/services.ts index c0c1e8550e..0920801e5c 100644 --- a/packages/frontend/core/src/modules/services.ts +++ b/packages/frontend/core/src/modules/services.ts @@ -11,7 +11,7 @@ import { LocalStorageGlobalCache, LocalStorageGlobalState, } from './infra-web/storage'; -import { CurrentPageService } from './page'; +import { Workbench } from './workbench'; import { CurrentWorkspaceService, WorkspaceLegacyProperties, @@ -22,7 +22,7 @@ export function configureBusinessServices(services: ServiceCollection) { services.add(CurrentWorkspaceService); services .scope(WorkspaceScope) - .add(CurrentPageService) + .add(Workbench) .add(WorkspacePropertiesAdapter, [Workspace]) .add(CollectionService, [Workspace]) .add(WorkspaceLegacyProperties, [Workspace]); diff --git a/packages/frontend/core/src/modules/workbench/browser-adapter.ts b/packages/frontend/core/src/modules/workbench/browser-adapter.ts new file mode 100644 index 0000000000..a24ab34e7a --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/browser-adapter.ts @@ -0,0 +1,105 @@ +import { useLiveData } from '@toeverything/infra/livedata'; +import { type Location } from 'history'; +import { useEffect } from 'react'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useLocation, useNavigate } from 'react-router-dom'; + +import type { Workbench } from './workbench'; + +/** + * This hook binds the workbench to the browser router. + * It listens to the active view and updates the browser location accordingly. + * It also listens to the browser location and updates the active view accordingly. + * + * The history of the active view and the browser are two different stacks. + * + * In the browser, we use browser history as the criterion, and view history is not very important. + * So our synchronization strategy is as follows: + * + * 1. When the active view history changed, we update the browser history, based on the update action. + * - If the update action is PUSH, we navigate to the new location. + * - If the update action is REPLACE, we replace the current location. + * 2. When the browser location changed, we update the active view history just in PUSH action. + * 3. To avoid infinite loop, we add a state to the location to indicate the source of the change. + */ +export function useBindWorkbenchToBrowserRouter( + workbench: Workbench, + basename: string +) { + const navigate = useNavigate(); + const browserLocation = useLocation(); + + const view = useLiveData(workbench.activeView); + + useEffect(() => { + return view.history.listen(update => { + if (update.action === 'POP') { + // This is because the history of view and browser are two different stacks, + // the POP action cannot be synchronized. + throw new Error('POP view history is not allowed on browser'); + } + + if (update.location.state === 'fromBrowser') { + return; + } + + const newBrowserLocation = viewLocationToBrowserLocation( + update.location, + basename + ); + + if (locationIsEqual(browserLocation, newBrowserLocation)) { + return; + } + + navigate(newBrowserLocation, { + state: 'fromView', + replace: update.action === 'REPLACE', + }); + }); + }, [basename, browserLocation, navigate, view]); + + useEffect(() => { + const newLocation = browserLocationToViewLocation( + browserLocation, + basename + ); + if (newLocation === null) { + return; + } + + view.history.push(newLocation, 'fromBrowser'); + }, [basename, browserLocation, view]); +} + +function browserLocationToViewLocation( + location: Location, + basename: string +): Location | null { + if (!location.pathname.startsWith(basename)) { + return null; + } + return { + ...location, + pathname: location.pathname.slice(basename.length), + }; +} + +function viewLocationToBrowserLocation( + location: Location, + basename: string +): Location { + return { + ...location, + pathname: `${basename}${location.pathname}`, + }; +} + +function locationIsEqual(a: Location, b: Location) { + return ( + a.hash === b.hash && + a.pathname === b.pathname && + a.search === b.search && + a.state === b.state + ); +} diff --git a/packages/frontend/core/src/modules/workbench/desktop-adapter.ts b/packages/frontend/core/src/modules/workbench/desktop-adapter.ts new file mode 100644 index 0000000000..c8d4aace2c --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/desktop-adapter.ts @@ -0,0 +1,49 @@ +import { type Location } from 'history'; +import { useEffect } from 'react'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useLocation } from 'react-router-dom'; + +import type { Workbench } from './workbench'; + +/** + * This hook binds the workbench to the browser router. + * + * It listens to the browser location and updates the active view accordingly. + * + * In desktop, we not really care about the browser history, we only listen it, + * and never modify it. + * + * REPLACE and POP action in browser history is not supported. + * To do these actions, you should use the workbench API. + */ +export function useBindWorkbenchToDesktopRouter( + workbench: Workbench, + basename: string +) { + const browserLocation = useLocation(); + + useEffect(() => { + const newLocation = browserLocationToViewLocation( + browserLocation, + basename + ); + if (newLocation === null) { + return; + } + + workbench.open(newLocation); + }, [basename, browserLocation, workbench]); +} + +function browserLocationToViewLocation( + location: Location, + basename: string +): Location | null { + if (!location.pathname.startsWith(basename)) { + return null; + } + return { + ...location, + pathname: location.pathname.slice(basename.length), + }; +} diff --git a/packages/frontend/core/src/modules/workbench/index.ts b/packages/frontend/core/src/modules/workbench/index.ts new file mode 100644 index 0000000000..ac6d78a1d2 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/index.ts @@ -0,0 +1,2 @@ +export * from './view'; +export * from './workbench'; diff --git a/packages/frontend/core/src/modules/workbench/view/index.ts b/packages/frontend/core/src/modules/workbench/view/index.ts new file mode 100644 index 0000000000..5638cb2c6d --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/index.ts @@ -0,0 +1 @@ +export * from './view'; diff --git a/packages/frontend/core/src/modules/workbench/view/view-root.tsx b/packages/frontend/core/src/modules/workbench/view/view-root.tsx new file mode 100644 index 0000000000..74548946bf --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/view-root.tsx @@ -0,0 +1,38 @@ +import { useLiveData } from '@toeverything/infra/livedata'; +import { useEffect, useMemo } from 'react'; +import { + createMemoryRouter, + RouterProvider, + UNSAFE_LocationContext, + UNSAFE_RouteContext, +} from 'react-router-dom'; + +import { viewRoutes } from '../../../router'; +import type { View } from './view'; + +export const ViewRoot = ({ view }: { view: View }) => { + const viewRouter = useMemo(() => createMemoryRouter(viewRoutes), []); + + const location = useLiveData(view.location); + + useEffect(() => { + viewRouter.navigate(location).catch(err => { + console.error('navigate error', err); + }); + }, [location, view, viewRouter]); + + // https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736 + return ( + + + + + + ); +}; diff --git a/packages/frontend/core/src/modules/workbench/view/view.ts b/packages/frontend/core/src/modules/workbench/view/view.ts new file mode 100644 index 0000000000..199d85d1e9 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/view.ts @@ -0,0 +1,33 @@ +import { LiveData } from '@toeverything/infra'; +import type { Location, To } from 'history'; +import { createMemoryHistory } from 'history'; +import { nanoid } from 'nanoid'; +import { Observable } from 'rxjs'; + +export class View { + id = nanoid(); + + history = createMemoryHistory(); + + location = LiveData.from( + new Observable(subscriber => { + subscriber.next(this.history.location); + return this.history.listen(update => { + subscriber.next(update.location); + }); + }), + this.history.location + ); + + push(path: To) { + this.history.push(path); + } + + go(n: number) { + this.history.go(n); + } + + replace(path: To) { + this.history.replace(path); + } +} diff --git a/packages/frontend/core/src/modules/workbench/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/workbench-link.tsx new file mode 100644 index 0000000000..ecc17f03ee --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/workbench-link.tsx @@ -0,0 +1,35 @@ +import { useService } from '@toeverything/infra/di'; +import type { To } from 'history'; +import { useCallback } from 'react'; + +import { Workbench } from './workbench'; + +export const WorkbenchLink = ({ + to, + children, + onClick, + ...other +}: React.PropsWithChildren< + { to: To } & React.HTMLProps +>) => { + const workbench = useService(Workbench); + const handleClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + // TODO: open this when multi view control is implemented + // if (environment.isDesktop && (event.ctrlKey || event.metaKey)) { + // workbench.open(to, { at: 'beside' }); + // } else { + workbench.open(to); + // } + + onClick?.(event); + }, + [onClick, to, workbench] + ); + return ( + + {children} + + ); +}; diff --git a/packages/frontend/core/src/modules/workbench/workbench-root.css.ts b/packages/frontend/core/src/modules/workbench/workbench-root.css.ts new file mode 100644 index 0000000000..5def4df04f --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/workbench-root.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const workbenchRootContainer = style({ + display: 'flex', + height: '100%', +}); + +export const workbenchViewContainer = style({ + flex: 1, +}); diff --git a/packages/frontend/core/src/modules/workbench/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/workbench-root.tsx new file mode 100644 index 0000000000..4477162988 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/workbench-root.tsx @@ -0,0 +1,54 @@ +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; +import { useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useBindWorkbenchToBrowserRouter } from './browser-adapter'; +import { useBindWorkbenchToDesktopRouter } from './desktop-adapter'; +import type { View } from './view'; +import { ViewRoot } from './view/view-root'; +import { Workbench } from './workbench'; +import { + workbenchRootContainer, + workbenchViewContainer, +} from './workbench-root.css'; + +const useAdapter = environment.isDesktop + ? useBindWorkbenchToDesktopRouter + : useBindWorkbenchToBrowserRouter; + +export const WorkbenchRoot = () => { + const workbench = useService(Workbench); + + // for debugging + (window as any).workbench = workbench; + + const views = useLiveData(workbench.views); + + const location = useLocation(); + const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/'; + + useAdapter(workbench, basename); + + return ( +
+ {views.map((view, index) => ( + + ))} +
+ ); +}; + +const WorkbenchView = ({ view, index }: { view: View; index: number }) => { + const workbench = useService(Workbench); + + const handleOnFocus = useCallback(() => { + workbench.active(index); + }, [workbench, index]); + + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/workbench/workbench.ts b/packages/frontend/core/src/modules/workbench/workbench.ts new file mode 100644 index 0000000000..b7a0168524 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/workbench.ts @@ -0,0 +1,101 @@ +import { Unreachable } from '@affine/env/constant'; +import { LiveData } from '@toeverything/infra'; +import type { To } from 'history'; +import { combineLatest, map, switchMap } from 'rxjs'; + +import { View } from './view'; + +export type WorkbenchPosition = 'beside' | 'active' | number; + +export class Workbench { + readonly views = new LiveData([new View()]); + + activeViewIndex = new LiveData(0); + activeView = LiveData.from( + combineLatest([this.views, this.activeViewIndex]).pipe( + map(([views, index]) => views[index]) + ), + this.views.value[this.activeViewIndex.value] + ); + + location = LiveData.from( + this.activeView.pipe(switchMap(view => view.location)), + this.views.value[this.activeViewIndex.value].history.location + ); + + active(index: number) { + this.activeViewIndex.next(index); + } + + createView(at: WorkbenchPosition = 'beside') { + const view = new View(); + const newViews = [...this.views.value]; + newViews.splice(this.indexAt(at), 0, view); + this.views.next(newViews); + return newViews.indexOf(view); + } + + open( + to: To, + { + at = 'active', + replaceHistory = false, + }: { at?: WorkbenchPosition; replaceHistory?: boolean } = {} + ) { + let view = this.viewAt(at); + if (!view) { + const newIndex = this.createView(at); + view = this.viewAt(newIndex); + if (!view) { + throw new Unreachable(); + } + } + if (replaceHistory) { + view.history.replace(to); + } else { + view.history.push(to); + } + } + + openPage(pageId: string) { + this.open(`/${pageId}`); + } + + openCollections() { + this.open('/collection'); + } + + openCollection(collectionId: string) { + this.open(`/collection/${collectionId}`); + } + + openAll() { + this.open('/all'); + } + + openTrash() { + this.open('/trash'); + } + + openTags() { + this.open('/tag'); + } + + openTag(tagId: string) { + this.open(`/tag/${tagId}`); + } + + viewAt(positionIndex: WorkbenchPosition): View | undefined { + return this.views.value[this.indexAt(positionIndex)]; + } + + private indexAt(positionIndex: WorkbenchPosition): number { + if (positionIndex === 'active') { + return this.activeViewIndex.value; + } + if (positionIndex === 'beside') { + return this.activeViewIndex.value + 1; + } + return positionIndex; + } +} diff --git a/packages/frontend/core/src/pages/share/share-detail-page.tsx b/packages/frontend/core/src/pages/share/share-detail-page.tsx index ce398b3a40..90a6430e2e 100644 --- a/packages/frontend/core/src/pages/share/share-detail-page.tsx +++ b/packages/frontend/core/src/pages/share/share-detail-page.tsx @@ -10,24 +10,24 @@ import { StaticBlobStorage, } from '@affine/workspace-impl'; import { Logo1Icon } from '@blocksuite/icons'; +import type { Page } from '@toeverything/infra'; import { EmptyBlobStorage, LocalBlobStorage, LocalSyncStorage, - Page, PageManager, type PageMode, ReadonlyMappingSyncStorage, RemoteBlobStorage, + ServiceProviderContext, useLiveData, useService, - useServiceOptional, WorkspaceIdContext, WorkspaceManager, WorkspaceScope, } from '@toeverything/infra'; import { noop } from 'foxact/noop'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import type { LoaderFunction } from 'react-router-dom'; import { isRouteErrorResponse, @@ -39,7 +39,6 @@ import { import { AppContainer } from '../../components/affine/app-container'; import { PageDetailEditor } from '../../components/page-detail-editor'; import { SharePageNotFoundError } from '../../components/share-page-not-found-error'; -import { CurrentPageService } from '../../modules/page'; import { CurrentWorkspaceService } from '../../modules/workspace'; import * as styles from './share-detail-page.css'; import { ShareFooter } from './share-footer'; @@ -131,6 +130,7 @@ export const Component = () => { const currentWorkspace = useService(CurrentWorkspaceService); const t = useAFFiNEI18N(); + const [page, setPage] = useState(null); useEffect(() => { // create a workspace for share page @@ -167,10 +167,8 @@ export const Component = () => { true ); - const currentPage = workspace.services.get(CurrentPageService); - currentWorkspace.openWorkspace(workspace); - currentPage.openPage(page); + setPage(page); }) .catch(err => { console.error(err); @@ -184,7 +182,6 @@ export const Component = () => { workspaceManager, ]); - const page = useServiceOptional(Page); const pageTitle = useLiveData(page?.title); usePageDocumentTitle(pageTitle); @@ -195,45 +192,47 @@ export const Component = () => { } return ( - - -
-
- - - - noop} - /> - {publishMode === 'page' ? : null} - - - - {loginStatus !== 'authenticated' ? ( - - - {t['com.affine.share-page.footer.built-with']()} - - - - ) : null} + + + +
+
+ + + + noop} + /> + {publishMode === 'page' ? : null} + + + + {loginStatus !== 'authenticated' ? ( + + + {t['com.affine.share-page.footer.built-with']()} + + + + ) : null} +
-
- - + + + ); }; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index e337a3cd9c..baced4e03a 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -18,8 +18,8 @@ import { Page, PageManager, PageRecordList, + ServiceProviderContext, useLiveData, - useServiceOptional, } from '@toeverything/infra'; import { appSettingAtom, Workspace } from '@toeverything/infra'; import { useService } from '@toeverything/infra'; @@ -31,6 +31,7 @@ import { useCallback, useEffect, useMemo, + useState, } from 'react'; import { useParams } from 'react-router-dom'; import type { Map as YMap } from 'yjs'; @@ -46,7 +47,6 @@ import { TopTip } from '../../../components/top-tip'; import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands'; import { usePageDocumentTitle } from '../../../hooks/use-global-state'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import { CurrentPageService } from '../../../modules/page'; import { performanceRenderLogger } from '../../../shared'; import { PageNotFound } from '../../404'; import * as styles from './detail-page.css'; @@ -270,28 +270,19 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { ); const pageManager = useService(PageManager); - const currentPageService = useService(CurrentPageService); + + const [page, setPage] = useState(null); useEffect(() => { if (!pageRecord) { return; } const { page, release } = pageManager.open(pageRecord.id); - currentPageService.openPage(page); + setPage(page); return () => { - currentPageService.closePage(); release(); }; - }, [currentPageService, pageManager, pageRecord]); - - const page = useServiceOptional(Page); - - const currentWorkspace = useService(Workspace); - - // set sync engine priority target - useEffect(() => { - currentWorkspace.setPriorityRule(id => id.endsWith(pageId)); - }, [pageId, currentWorkspace]); + }, [pageManager, pageRecord]); const jumpOnce = useLiveData(pageRecord?.meta.map(meta => meta.jumpOnce)); @@ -310,7 +301,11 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { return ; } - return ; + return ( + + + + ); }; export const Component = () => { diff --git a/packages/frontend/core/src/pages/workspace/index.tsx b/packages/frontend/core/src/pages/workspace/index.tsx index 7789867045..b596aa3a6d 100644 --- a/packages/frontend/core/src/pages/workspace/index.tsx +++ b/packages/frontend/core/src/pages/workspace/index.tsx @@ -5,7 +5,11 @@ import { WorkspaceListService, WorkspaceManager, } from '@toeverything/infra'; -import { useService, useServiceOptional } from '@toeverything/infra/di'; +import { + ServiceProviderContext, + useService, + useServiceOptional, +} from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { type ReactElement, @@ -14,10 +18,11 @@ import { useMemo, useState, } from 'react'; -import { Outlet, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; import { WorkspaceLayout } from '../../layouts/workspace-layout'; +import { WorkbenchRoot } from '../../modules/workbench/workbench-root'; import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace'; import { performanceRenderLogger } from '../../shared'; import { PageNotFound } from '../404'; @@ -105,12 +110,14 @@ export const Component = (): ReactElement => { } return ( - }> - - - - - - + + }> + + + + + + + ); }; diff --git a/packages/frontend/core/src/router.ts b/packages/frontend/core/src/router.tsx similarity index 60% rename from packages/frontend/core/src/router.ts rename to packages/frontend/core/src/router.tsx index e17c1f4060..d0b46d2c28 100644 --- a/packages/frontend/core/src/router.ts +++ b/packages/frontend/core/src/router.tsx @@ -2,44 +2,14 @@ import * as Sentry from '@sentry/react'; import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter as reactRouterCreateBrowserRouter } from 'react-router-dom'; -export const routes = [ +export const workbenchRoutes = [ { path: '/', lazy: () => import('./pages/index'), }, { - path: '/workspace/:workspaceId', + path: '/workspace/:workspaceId/*', lazy: () => import('./pages/workspace/index'), - children: [ - { - path: 'all', - lazy: () => import('./pages/workspace/all-page/all-page'), - }, - { - path: 'collection', - lazy: () => import('./pages/workspace/all-collection'), - }, - { - path: 'collection/:collectionId', - lazy: () => import('./pages/workspace/collection/index'), - }, - { - path: 'tag', - lazy: () => import('./pages/workspace/all-tag'), - }, - { - path: 'tag/:tagId', - lazy: () => import('./pages/workspace/tag'), - }, - { - path: 'trash', - lazy: () => import('./pages/workspace/trash-page'), - }, - { - path: ':pageId', - lazy: () => import('./pages/workspace/detail-page/detail-page'), - }, - ], }, { path: '/share/:workspaceId/:pageId', @@ -87,10 +57,45 @@ export const routes = [ }, ] satisfies [RouteObject, ...RouteObject[]]; +export const viewRoutes = [ + { + path: '/all', + lazy: () => import('./pages/workspace/all-page/all-page'), + }, + { + path: '/collection', + lazy: () => import('./pages/workspace/all-collection'), + }, + { + path: '/collection/:collectionId', + lazy: () => import('./pages/workspace/collection/index'), + }, + { + path: '/tag', + lazy: () => import('./pages/workspace/all-tag'), + }, + { + path: '/tag/:tagId', + lazy: () => import('./pages/workspace/tag'), + }, + { + path: '/trash', + lazy: () => import('./pages/workspace/trash-page'), + }, + { + path: '/:pageId', + lazy: () => import('./pages/workspace/detail-page/detail-page'), + }, + { + path: '*', + lazy: () => import('./pages/404'), + }, +] satisfies [RouteObject, ...RouteObject[]]; + const createBrowserRouter = Sentry.wrapCreateBrowserRouter( reactRouterCreateBrowserRouter ); -export const router = createBrowserRouter(routes, { +export const router = createBrowserRouter(workbenchRoutes, { future: { v7_normalizeFormMethod: true, }, diff --git a/packages/frontend/core/src/testing.ts b/packages/frontend/core/src/testing.ts index 8f466dfcf7..8a5c7373f0 100644 --- a/packages/frontend/core/src/testing.ts +++ b/packages/frontend/core/src/testing.ts @@ -7,7 +7,6 @@ import { WorkspaceManager, } from '@toeverything/infra'; -import { CurrentPageService } from './modules/page'; import { CurrentWorkspaceService } from './modules/workspace'; import { configureWebServices } from './web'; @@ -40,7 +39,6 @@ export async function configureTestingEnvironment() { const { page } = workspace.services.get(PageManager).open('page0'); rootServices.get(CurrentWorkspaceService).openWorkspace(workspace); - workspace.services.get(CurrentPageService).openPage(page); return { services: rootServices, workspace, page }; } diff --git a/tests/storybook/src/stories/core.stories.tsx b/tests/storybook/src/stories/core.stories.tsx index c5cf157abb..9f2c6dc705 100644 --- a/tests/storybook/src/stories/core.stories.tsx +++ b/tests/storybook/src/stories/core.stories.tsx @@ -1,4 +1,4 @@ -import { routes } from '@affine/core/router'; +import { workbenchRoutes } from '@affine/core/router'; import { assertExists } from '@blocksuite/global/utils'; import type { StoryFn } from '@storybook/react'; import { screen, userEvent, waitFor, within } from '@storybook/testing-library'; @@ -28,7 +28,7 @@ export const Index: StoryFn = () => { Index.decorators = [withRouter]; Index.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), }), }; @@ -74,7 +74,7 @@ SettingPage.play = async ({ canvasElement, step }) => { SettingPage.decorators = [withRouter]; SettingPage.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), }), }; @@ -84,7 +84,7 @@ export const NotFoundPage: StoryFn = () => { NotFoundPage.decorators = [withRouter]; NotFoundPage.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), location: { path: '/404', }, @@ -114,7 +114,7 @@ WorkspaceList.play = async ({ canvasElement }) => { WorkspaceList.decorators = [withRouter]; WorkspaceList.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), location: { path: '/', }, @@ -151,7 +151,7 @@ SearchPage.play = async ({ canvasElement }) => { SearchPage.decorators = [withRouter]; SearchPage.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), location: { path: '/', }, @@ -193,7 +193,7 @@ ImportPage.play = async ({ canvasElement }) => { ImportPage.decorators = [withRouter]; ImportPage.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), location: { path: '/', }, @@ -206,7 +206,7 @@ export const OpenAppPage: StoryFn = () => { OpenAppPage.decorators = [withRouter]; OpenAppPage.parameters = { reactRouter: reactRouterParameters({ - routing: reactRouterOutlets(routes), + routing: reactRouterOutlets(workbenchRoutes), location: { path: '/open-app/url', searchParams: { diff --git a/tests/storybook/src/stories/image-preview-modal.stories.tsx b/tests/storybook/src/stories/image-preview-modal.stories.tsx index 691d98e8ea..1612c62c4c 100644 --- a/tests/storybook/src/stories/image-preview-modal.stories.tsx +++ b/tests/storybook/src/stories/image-preview-modal.stories.tsx @@ -1,9 +1,13 @@ import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; import { ImagePreviewModal } from '@affine/core/components/image-preview'; -import { CurrentPageService } from '@affine/core/modules/page'; -import type { Page } from '@blocksuite/store'; import type { Meta } from '@storybook/react'; -import { PageManager, useService, Workspace } from '@toeverything/infra'; +import type { Page } from '@toeverything/infra'; +import { + PageManager, + ServiceProviderContext, + useService, + Workspace, +} from '@toeverything/infra'; import { initEmptyPage } from '@toeverything/infra'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -16,7 +20,6 @@ export default { export const Default = () => { const workspace = useService(Workspace); const pageManager = useService(PageManager); - const currentPageService = useService(CurrentPageService); const [page, setPage] = useState(null); @@ -25,7 +28,6 @@ export const Default = () => { initEmptyPage(bsPage); const { page, release } = pageManager.open(bsPage.meta.id); - currentPageService.openPage(page); fetch(new URL('@affine-test/fixtures/large-image.png', import.meta.url)) .then(res => res.arrayBuffer()) @@ -54,31 +56,35 @@ export const Default = () => { .catch(err => { console.error('Failed to load large-image.png', err); }); - setPage(bsPage); + setPage(page); return () => { release(); - currentPageService.closePage(); }; - }, [currentPageService, pageManager, workspace]); + }, [pageManager, workspace]); if (!page) { return null; } return ( -
- - {createPortal( - , - document.body - )} -
+ +
+ + {createPortal( + , + document.body + )} +
+
); }; diff --git a/yarn.lock b/yarn.lock index b69dee4ec9..1a7ae57cd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -356,6 +356,7 @@ __metadata: foxact: "npm:^0.2.31" fractional-indexing: "npm:^3.2.0" graphql: "npm:^16.8.1" + history: "npm:^5.3.0" html-webpack-plugin: "npm:^5.6.0" idb: "npm:^8.0.0" image-blob-reduce: "npm:^4.1.0" @@ -3356,7 +3357,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": version: 7.23.9 resolution: "@babel/runtime@npm:7.23.9" dependencies: @@ -22603,6 +22604,15 @@ __metadata: languageName: node linkType: hard +"history@npm:^5.3.0": + version: 5.3.0 + resolution: "history@npm:5.3.0" + dependencies: + "@babel/runtime": "npm:^7.7.6" + checksum: 10/52ba685b842ca6438ff11ef459951eb13d413ae715866a8dc5f7c3b1ea0cdeb8db6aabf7254551b85f56abc205e6e2d7e1d5afb36b711b401cdaff4f2cf187e9 + languageName: node + linkType: hard + "hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2"