mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 06:18:45 +08:00
feat(core): workbench system (#5837)
This commit is contained in:
@@ -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<Page | null>(null)
|
||||
)?.services;
|
||||
|
||||
return (
|
||||
<ServiceProviderContext.Provider
|
||||
value={pageProvider ?? workspaceProvider ?? rootProvider}
|
||||
>
|
||||
<ServiceProviderContext.Provider value={workspaceProvider ?? rootProvider}>
|
||||
{children}
|
||||
</ServiceProviderContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<Page | null>(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);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './current-page';
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './view';
|
||||
export * from './workbench';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './view';
|
||||
@@ -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 (
|
||||
<UNSAFE_LocationContext.Provider value={null as any}>
|
||||
<UNSAFE_RouteContext.Provider
|
||||
value={{
|
||||
outlet: null,
|
||||
matches: [],
|
||||
isDataRoute: false,
|
||||
}}
|
||||
>
|
||||
<RouterProvider router={viewRouter} />
|
||||
</UNSAFE_RouteContext.Provider>
|
||||
</UNSAFE_LocationContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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<Location>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLAnchorElement>
|
||||
>) => {
|
||||
const workbench = useService(Workbench);
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
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 (
|
||||
<a {...other} href="#" onClick={handleClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const workbenchRootContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const workbenchViewContainer = style({
|
||||
flex: 1,
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={workbenchRootContainer}>
|
||||
{views.map((view, index) => (
|
||||
<WorkbenchView key={view.id} view={view} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
|
||||
const workbench = useService(Workbench);
|
||||
|
||||
const handleOnFocus = useCallback(() => {
|
||||
workbench.active(index);
|
||||
}, [workbench, index]);
|
||||
|
||||
return (
|
||||
<div className={workbenchViewContainer} onMouseDownCapture={handleOnFocus}>
|
||||
<ViewRoot key={view.id} view={view} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user