refactor(core): new back&forward button base on workbench (#6012)

# feature:

## In Browser:
- hidden back&forward button in sidebar.
- back and forward is equal with `window.history.back()` `window.history.forward()`

## In Desktop:
- Back and forward can be controlled through the sidebar, cmdk, and shortcut keys.
- back and forward act on the currently **active** view.
- buttons change disable&enable style based on current active view history

# Refactor:

Move app-sidebar and app-container from @affine/component to @affine/core
This commit is contained in:
EYHN
2024-03-05 07:01:24 +00:00
parent b06aeb22dd
commit 7c76c25a9c
77 changed files with 625 additions and 349 deletions

View File

@@ -182,7 +182,7 @@ export class LiveData<T = unknown> implements InteropObservable<T> {
}
subscribe(
observer: Partial<Observer<T>> | ((value: T) => void) | undefined
observer?: Partial<Observer<T>> | ((value: T) => void) | undefined
): Subscription {
this.ops.next('watch');
const subscription = this.raw.subscribe(observer);

View File

@@ -1,99 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { IconButton } from '../../../ui/button';
import { Tooltip } from '../../../ui/tooltip';
import type { History } from '..';
import {
navHeaderButton,
navHeaderNavigationButtons,
navHeaderStyle,
} from '../index.css';
import { appSidebarOpenAtom } from '../index.jotai';
import { SidebarSwitch } from './sidebar-switch';
export type SidebarHeaderProps = {
router?: {
back: () => unknown;
forward: () => unknown;
history: History;
};
generalShortcutsInfo?: {
shortcuts: {
[title: string]: string[];
};
};
};
export const SidebarHeader = (props: SidebarHeaderProps) => {
const open = useAtomValue(appSidebarOpenAtom);
const t = useAFFiNEI18N();
const shortcuts = props.generalShortcutsInfo?.shortcuts;
const shortcutsObject = useMemo(() => {
const goBack = t['com.affine.keyboardShortcuts.goBack']();
const goBackShortcut = shortcuts?.[goBack];
const goForward = t['com.affine.keyboardShortcuts.goForward']();
const goForwardShortcut = shortcuts?.[goForward];
return {
goBack,
goBackShortcut,
goForward,
goForwardShortcut,
};
}, [shortcuts, t]);
return (
<div
className={navHeaderStyle}
data-open={open}
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
>
<SidebarSwitch show={open} />
<div className={navHeaderNavigationButtons}>
<Tooltip
content={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
side="bottom"
>
<IconButton
className={navHeaderButton}
data-testid="app-sidebar-arrow-button-back"
disabled={props.router?.history.current === 0}
onClick={() => {
props.router?.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
</Tooltip>
<Tooltip
content={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
side="bottom"
>
<IconButton
className={navHeaderButton}
data-testid="app-sidebar-arrow-button-forward"
disabled={
props.router
? (props.router.history.stack.length > 0 &&
props.router.history.current ===
props.router.history.stack.length - 1) ||
props.router.history.stack.length === 0
: true
}
onClick={() => {
props.router?.forward();
}}
>
<ArrowRightSmallIcon />
</IconButton>
</Tooltip>
</div>
</div>
);
};
export * from './sidebar-switch';

View File

@@ -1,2 +1,3 @@
export * from './scrollable';
export * from './scrollbar';
export * from './use-has-scroll-top';

View File

@@ -2,8 +2,8 @@ import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
import { type PropsWithChildren, useRef } from 'react';
import { useHasScrollTop } from '../../components/app-sidebar/sidebar-containers/use-has-scroll-top';
import * as styles from './index.css';
import { useHasScrollTop } from './use-has-scroll-top';
export type ScrollableContainerProps = {
showScrollTopBorder?: boolean;

View File

@@ -4,7 +4,6 @@ import '@affine/component/theme/theme.css';
import { AffineContext } from '@affine/component/context';
import { GlobalLoading } from '@affine/component/global-loading';
import { NotificationCenter } from '@affine/component/notification-center';
import { WorkspaceFallback } from '@affine/component/workspace';
import { createI18n, setUpLanguage } from '@affine/i18n';
import { CacheProvider } from '@emotion/react';
import { getCurrentStore } from '@toeverything/infra/atom';
@@ -13,6 +12,7 @@ import type { PropsWithChildren, ReactElement } from 'react';
import { lazy, memo, Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { WorkspaceFallback } from './components/workspace';
import { GlobalScopeProvider } from './modules/infra-web/global-scope';
import { CloudSessionProvider } from './providers/session-provider';
import { router } from './router';

View File

@@ -1,8 +1,9 @@
// these atoms cannot be moved to @affine/jotai since they use atoms from @affine/component
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { appSidebarOpenAtom } from '../components/app-sidebar';
export type Guide = {
// should show quick search tips
quickSearchTips: boolean;

View File

@@ -1,106 +0,0 @@
import { useAtom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { useCallback } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useNavigate } from 'react-router-dom';
import { router } from '../router';
export type History = {
stack: string[];
current: number;
skip: boolean;
};
export const MAX_HISTORY = 50;
const historyBaseAtom = atomWithStorage<History>(
'router-history',
{
stack: [],
current: 0,
skip: false,
},
createJSONStorage(() => sessionStorage)
);
historyBaseAtom.onMount = set => {
const unsubscribe = router.subscribe(state => {
set(prev => {
const url = state.location.pathname;
// if stack top is the same as current, skip
if (prev.stack[prev.current] === url) {
return prev;
}
if (prev.skip) {
return {
stack: [...prev.stack],
current: prev.current,
skip: false,
};
} else {
if (prev.current < prev.stack.length - 1) {
const newStack = prev.stack.slice(0, prev.current);
newStack.push(url);
if (newStack.length > MAX_HISTORY) {
newStack.shift();
}
return {
stack: newStack,
current: newStack.length - 1,
skip: false,
};
} else {
const newStack = [...prev.stack, url];
if (newStack.length > MAX_HISTORY) {
newStack.shift();
}
return {
stack: newStack,
current: newStack.length - 1,
skip: false,
};
}
}
});
});
return () => {
unsubscribe();
};
};
export function useHistoryAtom() {
const navigate = useNavigate();
const [base, setBase] = useAtom(historyBaseAtom);
return [
base,
useCallback(
(forward: boolean) => {
setBase(prev => {
if (forward) {
const target = Math.min(prev.stack.length - 1, prev.current + 1);
const url = prev.stack[target];
navigate(url);
return {
...prev,
current: target,
skip: true,
};
} else {
const target = Math.max(0, prev.current - 1);
const url = prev.stack[target];
navigate(url);
return {
...prev,
current: target,
skip: true,
};
}
});
},
[setBase, navigate]
),
] as const;
}

View File

@@ -1,9 +1,10 @@
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons';
import { registerAffineCommand } from '@toeverything/infra/command';
import type { createStore } from 'jotai';
import { appSidebarOpenAtom } from '../components/app-sidebar';
export function registerAffineLayoutCommands({
t,
store,

View File

@@ -1,9 +1,8 @@
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import {
AppContainer as AppContainerWithoutSettings,
type WorkspaceRootProps,
} from '@affine/component/workspace';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
} from '../workspace';
export const AppContainer = (props: WorkspaceRootProps) => {
const { appSettings } = useAppSettingHelper();

View File

@@ -1,6 +1,5 @@
import { ToolContainer } from '@affine/component/workspace';
import { HelpIsland } from '../../pure/help-island';
import { ToolContainer } from '../../workspace';
export const HubIsland = () => {
return (

View File

@@ -1,10 +1,10 @@
import { Tooltip } from '@affine/component';
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { Tooltip } from '../../../ui/tooltip';
import * as styles from './index.css';
export interface AddPageButtonProps {

View File

@@ -1,10 +1,10 @@
import { Skeleton } from '@affine/component';
import { ResizePanel } from '@affine/component/resize-panel';
import { useAtom, useAtomValue } from 'jotai';
import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react';
import { useEffect } from 'react';
import { Skeleton } from '../../ui/skeleton';
import { ResizePanel } from '../resize-panel';
import { fallbackHeaderStyle, fallbackStyle } from './fallback.css';
import {
floatingMaxWidth,
@@ -21,14 +21,11 @@ import {
appSidebarResizingAtom,
appSidebarWidthAtom,
} from './index.jotai';
import type { SidebarHeaderProps } from './sidebar-header';
import { SidebarHeader } from './sidebar-header';
export type AppSidebarProps = PropsWithChildren<
SidebarHeaderProps & {
hasBackground?: boolean;
}
>;
export type AppSidebarProps = PropsWithChildren<{
hasBackground?: boolean;
}>;
export type History = {
stack: string[];
@@ -97,10 +94,7 @@ export function AppSidebar(props: AppSidebarProps): ReactElement {
data-has-background={environment.isDesktop && props.hasBackground}
>
<nav className={navStyle} data-testid="app-sidebar">
<SidebarHeader
router={props.router}
generalShortcutsInfo={props.generalShortcutsInfo}
/>
<SidebarHeader />
<div className={navBodyStyle} data-testid="sliderBar-inner">
{props.children}
</div>

View File

@@ -1,11 +1,9 @@
import { useHasScrollTop } from '@affine/component';
import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
import { type PropsWithChildren, useRef } from 'react';
import * as styles from './index.css';
import { useHasScrollTop } from './use-has-scroll-top';
export { useHasScrollTop } from './use-has-scroll-top';
export function SidebarContainer({ children }: PropsWithChildren) {
return <div className={clsx([styles.baseContainer])}>{children}</div>;

View File

@@ -0,0 +1,23 @@
import { useAtomValue } from 'jotai';
import { NavigationButtons } from '../../../modules/navigation';
import { navHeaderStyle } from '../index.css';
import { appSidebarOpenAtom } from '../index.jotai';
import { SidebarSwitch } from './sidebar-switch';
export const SidebarHeader = () => {
const open = useAtomValue(appSidebarOpenAtom);
return (
<div
className={navHeaderStyle}
data-open={open}
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
>
<SidebarSwitch show={open} />
<NavigationButtons />
</div>
);
};
export * from './sidebar-switch';

View File

@@ -1,10 +1,9 @@
import { IconButton, Tooltip } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { IconButton } from '../../../ui/button';
import { Tooltip } from '../../../ui/tooltip';
import { appSidebarOpenAtom } from '../index.jotai';
import * as styles from './sidebar-switch.css';

View File

@@ -1,5 +1,4 @@
import { Scrollable } from '@affine/component';
import { useHasScrollTop } from '@affine/component/app-sidebar';
import { Scrollable, useHasScrollTop } from '@affine/component';
import clsx from 'clsx';
import {
type ForwardedRef,

View File

@@ -1,11 +1,8 @@
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
} from '@affine/component/app-sidebar';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react';
import { appSidebarFloatingAtom, appSidebarOpenAtom } from '../../app-sidebar';
import * as style from './style.css';
interface HeaderPros {

View File

@@ -47,13 +47,25 @@ export const WorkspaceModeFilterTab = ({
return (
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
<RadioButton spanStyle={styles.filterTab} value="docs">
<RadioButton
spanStyle={styles.filterTab}
value="docs"
data-testid="workspace-docs-button"
>
{t['com.affine.docs.header']()}
</RadioButton>
<RadioButton spanStyle={styles.filterTab} value="collections">
<RadioButton
spanStyle={styles.filterTab}
value="collections"
data-testid="workspace-collections-button"
>
{t['com.affine.collections.header']()}
</RadioButton>
<RadioButton spanStyle={styles.filterTab} value="tags">
<RadioButton
spanStyle={styles.filterTab}
value="tags"
data-testid="workspace-tags-button"
>
{t['Tags']()}
</RadioButton>
</RadioButtonGroup>

View File

@@ -1,5 +1,4 @@
import { AnimatedCollectionsIcon, toast } from '@affine/component';
import { MenuLinkItem as SidebarMenuLinkItem } from '@affine/component/app-sidebar';
import { RenameModal } from '@affine/component/rename-modal';
import { Button, IconButton } from '@affine/component/ui/button';
import {
@@ -23,6 +22,7 @@ import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag';
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
import { Workbench } from '../../../../modules/workbench';
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar';
import type { CollectionsListProps } from '../index';
import { Page } from './page';
import * as styles from './styles.css';

View File

@@ -1,4 +1,3 @@
import { MenuItem as CollectionItem } from '@affine/component/app-sidebar';
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
@@ -11,6 +10,7 @@ import { useParams } from 'react-router-dom';
import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag';
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
import { MenuItem as CollectionItem } from '../../../app-sidebar';
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
import { PostfixItem } from '../components/postfix-item';
import { ReferencePage } from '../components/reference-page';

View File

@@ -1,4 +1,3 @@
import { MenuLinkItem } from '@affine/component/app-sidebar';
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
@@ -8,6 +7,7 @@ import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { MenuLinkItem } from '../../../app-sidebar';
import * as styles from '../favorite/styles.css';
import { PostfixItem } from './postfix-item';
export interface ReferencePageProps {

View File

@@ -1,4 +1,3 @@
import { MenuLinkItem } from '@affine/component/app-sidebar';
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
@@ -11,6 +10,7 @@ import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag';
import { MenuLinkItem } from '../../../app-sidebar';
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
import { PostfixItem } from '../components/postfix-item';
import {

View File

@@ -1,8 +1,8 @@
import { MenuItem } from '@affine/component/app-sidebar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ImportIcon } from '@blocksuite/icons';
import type { BlockSuiteWorkspace } from '../../shared';
import { MenuItem } from '../app-sidebar';
import { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
const ImportPage = ({

View File

@@ -1,16 +1,4 @@
import { AnimatedDeleteIcon } from '@affine/component';
import {
AddPageButton,
AppDownloadButton,
AppSidebar,
appSidebarOpenAtom,
CategoryDivider,
MenuItem,
MenuLinkItem,
QuickSearchInput,
SidebarContainer,
SidebarScrollableContainer,
} from '@affine/component/app-sidebar';
import { Menu } from '@affine/component/ui/menu';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { CollectionService } from '@affine/core/modules/collection';
@@ -23,19 +11,27 @@ import { useLiveData, useService, type Workspace } from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { nanoid } from 'nanoid';
import type { HTMLAttributes, ReactElement } from 'react';
import { forwardRef, Suspense, useCallback, useEffect, useMemo } from 'react';
import { forwardRef, Suspense, useCallback, useEffect } from 'react';
import { openWorkspaceListModalAtom } from '../../atoms';
import { useHistoryAtom } from '../../atoms/history';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
import { getDropItemId } from '../../hooks/affine/use-sidebar-drag';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useRegisterBrowserHistoryCommands } from '../../hooks/use-browser-history-commands';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { Workbench } from '../../modules/workbench';
import { WorkspaceSubPath } from '../../shared';
import {
AddPageButton,
AppDownloadButton,
AppSidebar,
appSidebarOpenAtom,
CategoryDivider,
MenuItem,
MenuLinkItem,
QuickSearchInput,
SidebarContainer,
SidebarScrollableContainer,
} from '../app-sidebar';
import {
createEmptyCollection,
MoveToTrash,
@@ -109,7 +105,6 @@ export const RootAppSidebar = ({
const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom(
openWorkspaceListModalAtom
);
const generalShortcutsInfo = useGeneralShortcuts();
const currentPath = useLiveData(useService(Workbench).location).pathname;
const onClickNewPage = useAsyncCallback(async () => {
@@ -133,9 +128,6 @@ export const RootAppSidebar = ({
);
const navigateHelper = useNavigateHelper();
const backToAll = useCallback(() => {
navigateHelper.jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
}, [currentWorkspace.id, navigateHelper]);
// Listen to the "New Page" action from the menu
useEffect(() => {
if (environment.isDesktop) {
@@ -153,19 +145,6 @@ export const RootAppSidebar = ({
}
}, [sidebarOpen]);
const [history, setHistory] = useHistoryAtom();
const router = useMemo(() => {
return {
forward: () => {
setHistory(true);
},
back: () => {
setHistory(false);
},
history,
};
}, [history, setHistory]);
const dropItemId = getDropItemId('trash');
const trashDroppable = useDroppable({
id: dropItemId,
@@ -173,7 +152,6 @@ export const RootAppSidebar = ({
const closeUserWorkspaceList = useCallback(() => {
setOpenUserWorkspaceList(false);
}, [setOpenUserWorkspaceList]);
useRegisterBrowserHistoryCommands(router.back, router.forward);
const userInfo = useDeleteCollectionInfo();
const collection = useService(CollectionService);
@@ -199,7 +177,6 @@ export const RootAppSidebar = ({
return (
<AppSidebar
router={router}
hasBackground={
!(
appSettings.enableBlurBackground &&
@@ -207,7 +184,6 @@ export const RootAppSidebar = ({
environment.isMacOs
)
}
generalShortcutsInfo={generalShortcutsInfo}
>
<MoveToTrash.ConfirmModal
open={trashConfirmOpen}
@@ -249,7 +225,6 @@ export const RootAppSidebar = ({
icon={<FolderIcon />}
active={allPageActive}
path={paths.all(currentWorkspaceId)}
onClick={backToAll}
>
<span data-testid="all-pages">
{t['com.affine.workspaceSubPath.all']()}

View File

@@ -1,4 +1,3 @@
import { MenuItem } from '@affine/component/app-sidebar';
import {
useJournalInfoHelper,
useJournalRouteHelper,
@@ -9,6 +8,8 @@ import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons';
import { Doc, useServiceOptional } from '@toeverything/infra';
import { useParams } from 'react-router-dom';
import { MenuItem } from '../app-sidebar';
interface AppSidebarJournalButtonProps {
workspace: BlockSuiteWorkspace;
}

View File

@@ -1,6 +1,7 @@
import { AppUpdaterButton } from '@affine/component/app-sidebar/app-updater-button';
import { useAppUpdater } from '@affine/core/hooks/use-app-updater';
import { AppUpdaterButton } from '../app-sidebar';
export const UpdaterButton = () => {
const appUpdater = useAppUpdater();

View File

@@ -1,7 +1,8 @@
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
import { useAtom } from 'jotai';
import { useCallback, useMemo } from 'react';
import { appSidebarOpenAtom } from '../../components/app-sidebar';
export function useSwitchSidebarStatus() {
const [isOpened, setOpened] = useAtom(appSidebarOpenAtom);

View File

@@ -1,8 +1,3 @@
import {
AppSidebarFallback,
appSidebarResizingAtom,
} from '@affine/component/app-sidebar';
import { MainContainer, WorkspaceFallback } from '@affine/component/workspace';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status';
import { assertExists } from '@blocksuite/global/utils';
@@ -26,12 +21,17 @@ import { Map as YMap } from 'yjs';
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
import {
AppSidebarFallback,
appSidebarResizingAtom,
} from '../components/app-sidebar';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import {
type DraggableTitleCellData,
PageListDragOverlay,
} from '../components/page-list';
import { RootAppSidebar } from '../components/root-app-sidebar';
import { MainContainer, WorkspaceFallback } from '../components/workspace';
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
import { useSidebarDrag } from '../hooks/affine/use-sidebar-drag';

View File

@@ -0,0 +1,55 @@
import { LiveData } from '@toeverything/infra';
import type { Location } from 'history';
import { Observable, switchMap } from 'rxjs';
import type { Workbench } from '../../workbench';
export class Navigator {
constructor(private readonly workbench: Workbench) {}
private readonly history = this.workbench.activeView.map(
view => view.history
);
private readonly location = LiveData.from(
this.history.pipe(
switchMap(
history =>
new Observable<{ index: number; entries: Location[] }>(subscriber => {
subscriber.next({ index: history.index, entries: history.entries });
return history.listen(() => {
subscriber.next({
index: history.index,
entries: history.entries,
});
});
})
)
),
{ index: 0, entries: [] }
);
readonly backable = this.location.map(
({ index, entries }) => index > 0 && entries.length > 1
);
readonly forwardable = this.location.map(
({ index, entries }) => index < entries.length - 1
);
back() {
if (!environment.isDesktop) {
window.history.back();
} else {
this.history.value.back();
}
}
forward() {
if (!environment.isDesktop) {
window.history.forward();
} else {
this.history.value.forward();
}
}
}

View File

@@ -0,0 +1,2 @@
export { Navigator } from './entities/navigator';
export { NavigationButtons } from './view/navigation-buttons';

View File

@@ -0,0 +1,13 @@
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
alignItems: 'center',
columnGap: '32px',
});
export const button = style({
width: '32px',
height: '32px',
flexShrink: 0,
});

View File

@@ -0,0 +1,100 @@
import { IconButton, Tooltip } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useCallback, useEffect, useMemo } from 'react';
import { useGeneralShortcuts } from '../../../hooks/affine/use-shortcuts';
import { Navigator } from '../entities/navigator';
import * as styles from './navigation-buttons.css';
import { useRegisterNavigationCommands } from './use-register-navigation-commands';
export const NavigationButtons = () => {
const t = useAFFiNEI18N();
const shortcuts = useGeneralShortcuts().shortcuts;
useRegisterNavigationCommands();
const shortcutsObject = useMemo(() => {
const goBack = t['com.affine.keyboardShortcuts.goBack']();
const goBackShortcut = shortcuts?.[goBack];
const goForward = t['com.affine.keyboardShortcuts.goForward']();
const goForwardShortcut = shortcuts?.[goForward];
return {
goBack,
goBackShortcut,
goForward,
goForwardShortcut,
};
}, [shortcuts, t]);
const navigator = useService(Navigator);
const backable = useLiveData(navigator.backable);
const forwardable = useLiveData(navigator.forwardable);
const handleBack = useCallback(() => {
navigator.back();
}, [navigator]);
const handleForward = useCallback(() => {
navigator.forward();
}, [navigator]);
useEffect(() => {
const cb = (event: MouseEvent) => {
if (event.button === 3 || event.button === 4) {
event.preventDefault();
event.stopPropagation();
if (event.button === 3) {
navigator.back();
} else {
navigator.forward();
}
}
};
document.addEventListener('mouseup', cb);
return () => {
document.removeEventListener('mouseup', cb);
};
}, [navigator]);
if (!environment.isDesktop) {
return null;
}
return (
<div className={styles.container}>
<Tooltip
content={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
side="bottom"
>
<IconButton
className={styles.button}
data-testid="app-navigation-button-back"
disabled={!backable}
onClick={handleBack}
>
<ArrowLeftSmallIcon />
</IconButton>
</Tooltip>
<Tooltip
content={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
side="bottom"
>
<IconButton
className={styles.button}
data-testid="app-navigation-button-forward"
disabled={!forwardable}
onClick={handleForward}
>
<ArrowRightSmallIcon />
</IconButton>
</Tooltip>
</div>
);
};

View File

@@ -2,12 +2,13 @@ import {
PreconditionStrategy,
registerAffineCommand,
} from '@toeverything/infra/command';
import { useService } from '@toeverything/infra/di';
import { useEffect } from 'react';
export function useRegisterBrowserHistoryCommands(
back: () => unknown,
forward: () => unknown
) {
import { Navigator } from '../entities/navigator';
export function useRegisterNavigationCommands() {
const navigator = useService(Navigator);
useEffect(() => {
const unsubs: Array<() => void> = [];
@@ -22,7 +23,7 @@ export function useRegisterBrowserHistoryCommands(
binding: '$mod+[',
},
run() {
back();
navigator.back();
},
})
);
@@ -37,7 +38,7 @@ export function useRegisterBrowserHistoryCommands(
binding: '$mod+]',
},
run() {
forward();
navigator.forward();
},
})
);
@@ -45,5 +46,5 @@ export function useRegisterBrowserHistoryCommands(
return () => {
unsubs.forEach(unsub => unsub());
};
}, [back, forward]);
}, [navigator]);
}

View File

@@ -11,6 +11,7 @@ import {
LocalStorageGlobalCache,
LocalStorageGlobalState,
} from './infra-web/storage';
import { Navigator } from './navigation';
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
import { Workbench } from './workbench';
import {
@@ -24,6 +25,7 @@ export function configureBusinessServices(services: ServiceCollection) {
services
.scope(WorkspaceScope)
.add(Workbench)
.add(Navigator, [Workbench])
.add(RightSidebar)
.add(WorkspacePropertiesAdapter, [Workspace])
.add(CollectionService, [Workspace])

View File

@@ -1,15 +1,25 @@
import { LiveData } from '@toeverything/infra';
import type { Location, To } from 'history';
import { createMemoryHistory } from 'history';
import { nanoid } from 'nanoid';
import { Observable } from 'rxjs';
import { createIsland } from '../../../utils/island';
import { createNavigableHistory } from '../../../utils/navigable-history';
export class View {
constructor(defaultPath: To = { pathname: '/all' }) {
this.history = createNavigableHistory({
initialEntries: [defaultPath],
initialIndex: 0,
});
}
id = nanoid();
history = createMemoryHistory();
history = createNavigableHistory({
initialEntries: ['/all'],
initialIndex: 0,
});
location = LiveData.from<Location>(
new Observable(subscriber => {
@@ -20,6 +30,17 @@ export class View {
}),
this.history.location
);
entries = LiveData.from<Location[]>(
new Observable(subscriber => {
subscriber.next(this.history.entries);
return this.history.listen(() => {
subscriber.next(this.history.entries);
});
}),
this.history.entries
);
size = new LiveData(100);
header = createIsland();

View File

@@ -27,8 +27,8 @@ export class Workbench {
this.activeViewIndex.next(index);
}
createView(at: WorkbenchPosition = 'beside') {
const view = new View();
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
const view = new View(defaultLocation);
const newViews = [...this.views.value];
newViews.splice(this.indexAt(at), 0, view);
this.views.next(newViews);
@@ -44,16 +44,17 @@ export class Workbench {
) {
let view = this.viewAt(at);
if (!view) {
const newIndex = this.createView(at);
const newIndex = this.createView(at, to);
view = this.viewAt(newIndex);
if (!view) {
throw new Unreachable();
}
}
if (replaceHistory) {
view.history.replace(to);
} else {
view.history.push(to);
if (replaceHistory) {
view.history.replace(to);
} else {
view.history.push(to);
}
}
}

View File

@@ -30,6 +30,13 @@ export function useBindWorkbenchToDesktopRouter(
if (newLocation === null) {
return;
}
if (
workbench.location.value.pathname === newLocation.pathname &&
workbench.location.value.search === newLocation.search &&
workbench.location.value.hash === newLocation.hash
) {
return;
}
workbench.open(newLocation);
}, [basename, browserLocation, workbench]);

View File

@@ -1,8 +1,4 @@
import { IconButton } from '@affine/component';
import {
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { RightSidebarIcon } from '@blocksuite/icons';
import { useLiveData } from '@toeverything/infra';
@@ -10,6 +6,10 @@ import { useService } from '@toeverything/infra/di';
import { useAtomValue } from 'jotai';
import { Suspense, useCallback } from 'react';
import {
appSidebarOpenAtom,
SidebarSwitch,
} from '../../../components/app-sidebar';
import { RightSidebar } from '../../right-sidebar';
import * as styles from './route-container.css';
import { useView } from './use-view';

View File

@@ -1,5 +1,4 @@
import { Menu } from '@affine/component/ui/menu';
import { WorkspaceFallback } from '@affine/component/workspace';
import { WorkspaceManager } from '@toeverything/infra';
import { WorkspaceListService } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
@@ -9,6 +8,7 @@ import { type LoaderFunction, redirect } from 'react-router-dom';
import { createFirstAppData } from '../bootstrap/first-app-data';
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
import { WorkspaceFallback } from '../components/workspace';
import { appConfigStorage } from '../hooks/use-app-config-storage';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../shared';

View File

@@ -1,5 +1,4 @@
import { Scrollable } from '@affine/component';
import { MainContainer } from '@affine/component/workspace';
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state';
import { WorkspaceFlavour } from '@affine/env/workspace';
@@ -39,6 +38,7 @@ 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 { MainContainer } from '../../components/workspace';
import { CurrentWorkspaceService } from '../../modules/workspace';
import * as styles from './share-detail-page.css';
import { ShareFooter } from './share-footer';

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});

View File

@@ -18,6 +18,7 @@ import { CollectionService } from '../../../modules/collection';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { EmptyCollectionList } from '../page-list-empty';
import { AllCollectionHeader } from './header';
import * as styles from './index.css';
export const AllCollection = () => {
const t = useAFFiNEI18N();
@@ -65,25 +66,27 @@ export const AllCollection = () => {
/>
</ViewHeaderIsland>
<ViewBodyIsland>
{collectionMetas.length > 0 ? (
<VirtualizedCollectionList
collections={collections}
collectionMetas={collectionMetas}
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
node={node}
config={config}
handleCreateCollection={handleCreateCollection}
/>
) : (
<EmptyCollectionList
heading={
<CollectionListHeader
node={node}
onCreate={handleCreateCollection}
/>
}
/>
)}
<div className={styles.body}>
{collectionMetas.length > 0 ? (
<VirtualizedCollectionList
collections={collections}
collectionMetas={collectionMetas}
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
node={node}
config={config}
handleCreateCollection={handleCreateCollection}
/>
) : (
<EmptyCollectionList
heading={
<CollectionListHeader
node={node}
onCreate={handleCreateCollection}
/>
}
/>
)}
</div>
</ViewBodyIsland>
</>
);

View File

@@ -1,5 +1,4 @@
import type { InlineEditHandle } from '@affine/component';
import { appSidebarFloatingAtom } from '@affine/component/app-sidebar';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button';
@@ -12,6 +11,7 @@ import { useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
import { SharePageButton } from '../../../components/affine/share-page-modal';
import { appSidebarFloatingAtom } from '../../../components/app-sidebar';
import { BlocksuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header/title/index';
import { HeaderDivider } from '../../../components/pure/header';
import * as styles from './detail-page-header.css';

View File

@@ -28,6 +28,7 @@ import {
type ReactElement,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
@@ -269,7 +270,7 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
const [page, setPage] = useState<Doc | null>(null);
useEffect(() => {
useLayoutEffect(() => {
if (!pageRecord) {
return;
}

View File

@@ -1,4 +1,3 @@
import { WorkspaceFallback } from '@affine/component/workspace';
import { useWorkspace } from '@affine/core/hooks/use-workspace';
import {
Workspace,
@@ -22,6 +21,7 @@ import { useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { HubIsland } from '../../components/affine/hub-island';
import { WorkspaceFallback } from '../../components/workspace';
import { WorkspaceLayout } from '../../layouts/workspace-layout';
import { RightSidebarContainer } from '../../modules/right-sidebar';
import { WorkbenchRoot } from '../../modules/workbench';

View File

@@ -0,0 +1,221 @@
import type { Blocker, Listener, Location, To } from 'history';
import {
Action,
createPath,
type MemoryHistory,
type MemoryHistoryOptions,
parsePath,
} from 'history';
export interface NavigableHistory extends MemoryHistory {
entries: Location[];
}
/**
* Same as `createMemoryHistory` from `history` package, but with additional `entries` property.
*
* Original `MemoryHistory` does not have `entries` property, so we can't get `backable` and `forwardable` state which
* is needed for implementing back and forward buttons.
*
* @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#creatememoryhistory
*/
export function createNavigableHistory(
options: MemoryHistoryOptions = {}
): NavigableHistory {
const { initialEntries = ['/'], initialIndex } = options;
const entries: Location[] = initialEntries.map(entry => {
const location = Object.freeze<Location>({
pathname: '/',
search: '',
hash: '',
state: null,
key: createKey(),
...(typeof entry === 'string' ? parsePath(entry) : entry),
});
warning(
location.pathname.charAt(0) === '/',
`Relative pathnames are not supported in createMemoryHistory({ initialEntries }) (invalid entry: ${JSON.stringify(
entry
)})`
);
return location;
});
let index = clamp(
initialIndex == null ? entries.length - 1 : initialIndex,
0,
entries.length - 1
);
let action = Action.Pop;
let location = entries[index];
const listeners = createEvents<Listener>();
const blockers = createEvents<Blocker>();
function createHref(to: To) {
return typeof to === 'string' ? to : createPath(to);
}
function getNextLocation(to: To, state: any = null): Location {
return Object.freeze<Location>({
pathname: location.pathname,
search: '',
hash: '',
...(typeof to === 'string' ? parsePath(to) : to),
state,
key: createKey(),
});
}
function allowTx(action: Action, location: Location, retry: () => void) {
return (
!blockers.length || (blockers.call({ action, location, retry }), false)
);
}
function applyTx(nextAction: Action, nextLocation: Location) {
action = nextAction;
location = nextLocation;
listeners.call({ action, location });
}
function push(to: To, state?: any) {
const nextAction = Action.Push;
const nextLocation = getNextLocation(to, state);
function retry() {
push(to, state);
}
warning(
location.pathname.charAt(0) === '/',
`Relative pathnames are not supported in memory history.push(${JSON.stringify(
to
)})`
);
if (allowTx(nextAction, nextLocation, retry)) {
index += 1;
entries.splice(index, entries.length, nextLocation);
applyTx(nextAction, nextLocation);
}
}
function replace(to: To, state?: any) {
const nextAction = Action.Replace;
const nextLocation = getNextLocation(to, state);
function retry() {
replace(to, state);
}
warning(
location.pathname.charAt(0) === '/',
`Relative pathnames are not supported in memory history.replace(${JSON.stringify(
to
)})`
);
if (allowTx(nextAction, nextLocation, retry)) {
entries[index] = nextLocation;
applyTx(nextAction, nextLocation);
}
}
function go(delta: number) {
const nextIndex = clamp(index + delta, 0, entries.length - 1);
const nextAction = Action.Pop;
const nextLocation = entries[nextIndex];
function retry() {
go(delta);
}
if (allowTx(nextAction, nextLocation, retry)) {
index = nextIndex;
applyTx(nextAction, nextLocation);
}
}
const history: NavigableHistory = {
get index() {
return index;
},
get action() {
return action;
},
get location() {
return location;
},
get entries() {
return entries;
},
createHref,
push,
replace,
go,
back() {
go(-1);
},
forward() {
go(1);
},
listen(listener) {
return listeners.push(listener);
},
block(blocker) {
return blockers.push(blocker);
},
};
return history;
}
function createKey() {
return Math.random().toString(36).substr(2, 8);
}
function warning(cond: any, message: string) {
if (!cond) {
// eslint-disable-next-line no-console
if (typeof console !== 'undefined') console.warn(message);
try {
// Welcome to debugging history!
//
// This error is thrown as a convenience so you can more easily
// find the source for a warning that appears in the console by
// enabling "pause on exceptions" in your JavaScript debugger.
throw new Error(message);
// eslint-disable-next-line no-empty
} catch (e) {}
}
}
function clamp(n: number, lowerBound: number, upperBound: number) {
return Math.min(Math.max(n, lowerBound), upperBound);
}
type Events<F> = {
length: number;
push: (fn: F) => () => void;
call: (arg: any) => void;
};
// eslint-disable-next-line @typescript-eslint/ban-types
function createEvents<F extends Function>(): Events<F> {
let handlers: F[] = [];
return {
get length() {
return handlers.length;
},
push(fn: F) {
handlers.push(fn);
return function () {
handlers = handlers.filter(handler => handler !== fn);
};
},
call(arg) {
handlers.forEach(fn => fn && fn(arg));
},
};
}