feat(core): improve mobile perf (#15317)

#### PR Dependency Tree


* **PR #15317** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Virtualized mobile navigation with shell navigation and interactive
swipe menus; coordinated mobile back handling with interactive
phases/state restoration.
* Added shared auth request proxy and message-port based token handling
across mobile and worker flows.
* **Bug Fixes**
  * Hydrated remote worker error stacks for calls and observable errors.
* Improved SQLite FTS/indexer and nbstore optional text handling;
refined docs-search ref parsing and notification loading/retry.
* **Refactor / UX**
* Modal focus-preservation and pointer behavior updates; improved mobile
menu controls and back gesture plugins.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-23 00:23:21 +08:00
committed by GitHub
parent 02e75862cc
commit 1d36e2e4b2
160 changed files with 6660 additions and 1890 deletions
@@ -53,19 +53,21 @@ export function registerAffineCreationCommands({
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:new-workspace',
category: 'affine:creation',
icon: <PlusIcon />,
label: t['com.affine.cmdk.affine.new-workspace'](),
run() {
track.$.cmdk.workspace.createWorkspace();
if (!BUILD_CONFIG.isMobileEdition) {
unsubs.push(
registerAffineCommand({
id: 'affine:new-workspace',
category: 'affine:creation',
icon: <PlusIcon />,
label: t['com.affine.cmdk.affine.new-workspace'](),
run() {
track.$.cmdk.workspace.createWorkspace();
globalDialogService.open('create-workspace', {});
},
})
);
globalDialogService.open('create-workspace', {});
},
})
);
}
unsubs.push(
registerAffineCommand({
id: 'affine:import-workspace',
@@ -36,6 +36,7 @@ export const ConfigModal = ({
const t = useI18n();
return (
<Modal
preserveEditingFocusOnAction
onOpenChange={onOpenChange}
open={open}
fullScreen={variant === 'page'}
@@ -79,10 +79,21 @@ export const listEmptyDescription = style({
});
export const error = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
color: cssVarV2('status/error'),
fontSize: '14px',
lineHeight: '22px',
padding: '4px 2px',
padding: '8px',
});
export const errorEmptyTitle = style({
color: cssVarV2('status/error'),
fontSize: '14px',
lineHeight: '22px',
textAlign: 'center',
});
export const itemContainer = style({
@@ -9,7 +9,8 @@ import {
Scrollable,
Skeleton,
} from '@affine/component';
import { InvitationService } from '@affine/core/modules/cloud';
import { AuthService, InvitationService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import {
type Notification,
NotificationListService,
@@ -54,10 +55,18 @@ import * as styles from './list.style.css';
export const NotificationList = () => {
const t = useI18n();
const notificationListService = useService(NotificationListService);
const authService = useService(AuthService);
const globalDialogService = useService(GlobalDialogService);
const authStatus = useLiveData(authService.session.status$);
const notifications = useLiveData(notificationListService.notifications$);
const isLoading = useLiveData(notificationListService.isLoading$);
const error = useLiveData(notificationListService.error$);
const hasMore = useLiveData(notificationListService.hasMore$);
const showLoadingMore =
authStatus === 'authenticated' &&
notifications.length > 0 &&
hasMore &&
isLoading;
const loadMoreIndicatorRef = useRef<HTMLDivElement>(null);
const userFriendlyError = useMemo(() => {
@@ -65,23 +74,31 @@ export const NotificationList = () => {
}, [error]);
useLayoutEffect(() => {
// reset the notification list when the component is mounted
notificationListService.reset();
notificationListService.loadMore();
}, [notificationListService]);
if (authStatus === 'authenticated') notificationListService.loadMore();
}, [authStatus, notificationListService]);
useEffect(() => {
if (loadMoreIndicatorRef.current) {
let previousIsIntersecting = false;
return observeIntersection(loadMoreIndicatorRef.current, entity => {
if (entity.isIntersecting && !previousIsIntersecting && hasMore) {
if (
authStatus === 'authenticated' &&
entity.isIntersecting &&
!previousIsIntersecting &&
hasMore
) {
notificationListService.loadMore();
}
previousIsIntersecting = entity.isIntersecting;
});
}
return;
}, [hasMore, notificationListService]);
}, [authStatus, hasMore, notificationListService]);
const handleSignIn = useCallback(() => {
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
const handleDeleteAll = useCallback(() => {
notificationListService.readAllNotifications().catch(err => {
@@ -110,7 +127,9 @@ export const NotificationList = () => {
</div>
<Scrollable.Root className={styles.scrollRoot}>
<Scrollable.Viewport className={styles.scrollViewport}>
{notifications.length > 0 ? (
{authStatus === 'unauthenticated' ? (
<NotificationSignIn onSignIn={handleSignIn} />
) : notifications.length > 0 ? (
<ul className={styles.itemList}>
{notifications.map(notification => (
<li key={notification.id}>
@@ -118,22 +137,32 @@ export const NotificationList = () => {
</li>
))}
{userFriendlyError && (
<div className={styles.error}>{userFriendlyError.message}</div>
<li>
<NotificationError
message={userFriendlyError.message}
onRetry={() => notificationListService.retry()}
/>
</li>
)}
</ul>
) : isLoading ? (
<NotificationItemSkeleton />
) : userFriendlyError ? (
<div className={styles.error}>{userFriendlyError.message}</div>
<NotificationErrorEmpty
message={userFriendlyError.message}
onRetry={() => notificationListService.retry()}
/>
) : (
<NotificationListEmpty />
)}
<div
ref={loadMoreIndicatorRef}
className={hasMore ? styles.loadMoreIndicator : ''}
className={showLoadingMore ? styles.loadMoreIndicator : ''}
>
{hasMore ? t['com.affine.notification.loading-more']() : null}
{showLoadingMore
? t['com.affine.notification.loading-more']()
: null}
</div>
</Scrollable.Viewport>
<Scrollable.Scrollbar />
@@ -142,6 +171,61 @@ export const NotificationList = () => {
);
};
const NotificationSignIn = ({ onSignIn }: { onSignIn: () => void }) => {
const t = useI18n();
return (
<div className={styles.listEmpty}>
<div className={styles.listEmptyIconContainer}>
<NotificationIcon width={24} height={24} />
</div>
<div className={styles.listEmptyTitle}>
{t['com.affine.ai.login-required.dialog-title']()}
</div>
<div className={styles.listEmptyDescription}>
{t['com.affine.notification.empty.description']()}
</div>
<Button variant="primary" onClick={onSignIn}>
{t['com.affine.ai.login-required.dialog-confirm']()}
</Button>
</div>
);
};
const NotificationErrorEmpty = ({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) => {
const t = useI18n();
return (
<div className={styles.listEmpty}>
<div className={styles.listEmptyIconContainer}>
<NotificationIcon width={24} height={24} />
</div>
<div className={styles.errorEmptyTitle}>{message}</div>
<Button onClick={onRetry}>{t['Retry']()}</Button>
</div>
);
};
const NotificationError = ({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) => {
const t = useI18n();
return (
<div className={styles.error}>
<span>{message}</span>
<Button onClick={onRetry}>{t['Retry']()}</Button>
</div>
);
};
const NotificationListEmpty = () => {
const t = useI18n();
return (
@@ -311,6 +311,7 @@ export const TagsEditor = ({
onClick: () => onSelectTagOption(tag),
onMouseEnter: () => setFocusedIndex(idx),
['data-testid']: 'tag-selector-item',
['data-modal-action']: '',
['data-focused']: safeFocusedIndex === idx,
className: styles.tagSelectorItem,
};
@@ -39,14 +39,18 @@ export const Component = ({
defaultIndexRoute = 'all',
children,
fallback,
createErrorFallback,
}: {
defaultIndexRoute?: string;
children?: ReactNode;
fallback?: ReactNode;
createErrorFallback?: (retry: () => void) => ReactNode;
}) => {
// navigating and creating may be slow, to avoid flickering, we show workspace fallback
const [navigating, setNavigating] = useState(true);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(false);
const [createAttempt, setCreateAttempt] = useState(0);
const authService = useService(AuthService);
const defaultServerService = useService(DefaultServerService);
@@ -118,6 +122,9 @@ export const Component = ({
}
} else {
if (list.length === 0) {
if (BUILD_CONFIG.isMobileEdition && enableLocalWorkspace) {
return;
}
setNavigating(false);
return;
}
@@ -151,7 +158,12 @@ export const Component = ({
return;
}
createFirstAppData(workspacesService)
const creation = createFirstAppData(workspacesService);
if (!creation) return;
setCreateError(false);
setCreating(true);
creation
.then(createdWorkspace => {
if (createdWorkspace) {
if (createdWorkspace.defaultPageId) {
@@ -166,21 +178,29 @@ export const Component = ({
})
.catch(err => {
console.error('Failed to create first app data', err);
setCreateError(true);
})
.finally(() => {
setCreating(false);
});
}, [
jumpToPage,
jumpToSignIn,
openPage,
workspacesService,
loggedIn,
listIsLoading,
list,
enableLocalWorkspace,
createAttempt,
]);
const retryCreate = useCallback(() => {
setCreateAttempt(attempt => attempt + 1);
}, []);
if (createError && createErrorFallback) {
return createErrorFallback(retryCreate);
}
if (navigating || creating) {
return fallback ?? <AppContainer fallback />;
}
@@ -1,6 +1,6 @@
import { SafeArea, Skeleton } from '@affine/component';
import { WorkspaceSelector } from '../workspace-selector';
import { Button, SafeArea, Skeleton } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { cssVarV2 } from '@toeverything/theme/v2';
const SectionTitleFallback = () => {
return (
@@ -49,7 +49,9 @@ const Section = () => {
);
};
export const AppFallback = () => {
export const AppFallback = ({ onRetry }: { onRetry?: () => void }) => {
const t = useI18n();
return (
<SafeArea top bottom style={{ height: '100dvh', overflow: 'hidden' }}>
{/* notification and setting */}
@@ -73,7 +75,10 @@ export const AppFallback = () => {
</div>
{/* workspace card */}
<div style={{ padding: '4px 16px' }}>
<WorkspaceSelector />
<Skeleton
animation="wave"
style={{ width: 220, height: 40, borderRadius: 8 }}
/>
</div>
{/* search */}
<div style={{ padding: '10px 16px 15px' }}>
@@ -92,6 +97,33 @@ export const AppFallback = () => {
</div>
<Section />
<Section />
{onRetry ? (
<div
role="alert"
style={{
position: 'fixed',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
padding: 24,
background: cssVarV2('layer/background/mobile/primary'),
}}
>
<div style={{ maxWidth: 320, textAlign: 'center' }}>
{t['error.INTERNAL_SERVER_ERROR']()}
</div>
<Button
variant="primary"
style={{ minWidth: 104, height: 44 }}
onClick={onRetry}
>
{t['Retry']()}
</Button>
</div>
) : null}
</SafeArea>
);
};
@@ -32,10 +32,10 @@ export const AppTabCreate = ({ tab }: AppTabCustomFCProps) => {
if (enablePageTemplate && pageTemplateDocId) {
const docId =
await docsService.duplicateFromTemplate(pageTemplateDocId);
workbench.openDoc({ docId, fromTab: 'true' });
workbench.openDoc(docId);
} else {
const doc = pageHelper.createPage(undefined, { show: false });
workbench.openDoc({ docId: doc.id, fromTab: 'true' });
workbench.openDoc(doc.id);
}
track.$.navigationPanel.$.createDoc();
},
@@ -19,7 +19,7 @@ export const AppTabJournal = ({ tab }: AppTabCustomFCProps) => {
const JournalIcon = useLiveData(docDisplayMetaService.icon$(maybeDocId));
const handleOpenToday = useCallback(() => {
workbench.open('/journals', { at: 'active' });
workbench.open('/journals', { at: 'active', replaceHistory: true });
}, [workbench]);
const Icon = journalDate ? JournalIcon : TodayIcon;
@@ -1,6 +1,8 @@
export * from './app-tabs';
export * from './doc-card';
export * from './doc-info';
export * from './mobile-shell-host';
export * from './navigation/menu-host';
export * from './navigation-back';
export * from './page-header';
export * from './rename';
@@ -0,0 +1,34 @@
import { ModalConfigContext } from '@affine/component';
import { useService } from '@toeverything/infra';
import { fallbackVar } from '@vanilla-extract/css';
import { useCallback, useMemo } from 'react';
import { MobileBackCoordinator } from '../modules/back-coordinator';
import { VirtualKeyboardService } from '../modules/virtual-keyboard';
import { globalVars } from '../styles/variables.css';
export const MobileModalConfigProvider = ({
children,
}: React.PropsWithChildren) => {
const backCoordinator = useService(MobileBackCoordinator);
useService(VirtualKeyboardService);
const onOpen = useCallback(
(close: () => void) =>
backCoordinator.registerVisual({ handle: close }).dispose,
[backCoordinator]
);
const value = useMemo(
() => ({
onOpen,
dynamicKeyboardHeight: fallbackVar(globalVars.appKeyboardHeight, '0px'),
}),
[onOpen]
);
return (
<ModalConfigContext.Provider value={value}>
{children}
</ModalConfigContext.Provider>
);
};
@@ -0,0 +1,62 @@
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
createContext,
type PropsWithChildren,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { MobileBackCoordinator } from '../modules/back-coordinator';
import { AppTabs } from './app-tabs';
import { MobileNavigationMenuHost } from './navigation/menu-host';
type TabsMetadata = {
background?: string;
hidden?: boolean;
};
const TabsMetadataContext = createContext<
((metadata: TabsMetadata | null) => void) | null
>(null);
export const useMobileShellTabs = (metadata: TabsMetadata = {}) => {
const setMetadata = useContext(TabsMetadataContext);
const background = metadata.background;
const hidden = metadata.hidden;
useEffect(() => {
setMetadata?.({ background, hidden });
return () => setMetadata?.(null);
}, [background, hidden, setMetadata]);
};
export const MobileShellHost = ({ children }: PropsWithChildren) => {
const [metadata, setMetadata] = useState<TabsMetadata | null>(null);
const value = useMemo(() => setMetadata, []);
const workbench = useService(WorkbenchService).workbench;
const workspaceSelectorOpen = useLiveData(workbench.workspaceSelectorOpen$);
const backCoordinator = useService(MobileBackCoordinator);
useEffect(() => {
if (!workspaceSelectorOpen) return;
const registration = backCoordinator.registerVisual({
handle: () => workbench.closeWorkspaceSelector(),
});
return registration.dispose;
}, [backCoordinator, workbench, workspaceSelectorOpen]);
return (
<TabsMetadataContext.Provider value={value}>
<MobileNavigationMenuHost>{children}</MobileNavigationMenuHost>
<AppTabs
background={
metadata?.background ?? cssVarV2('layer/background/mobile/primary')
}
hidden={metadata?.hidden}
/>
</TabsMetadataContext.Provider>
);
};
@@ -5,9 +5,9 @@ import {
} from '@affine/component';
import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { NavigationGestureService } from '../../modules/navigation-gesture';
import { MobileBackCoordinator } from '../../modules/back-coordinator';
export interface NavigationBackButtonProps extends IconButtonProps {
backAction?: () => void;
@@ -23,23 +23,15 @@ export const NavigationBackButton = ({
style: propsStyle,
...otherProps
}: NavigationBackButtonProps) => {
const navigationGesture = useService(NavigationGestureService);
const backCoordinator = useService(MobileBackCoordinator);
const isInsideModal = useIsInsideModal();
const handleRouteBack = useCallback(() => {
backAction ? backAction() : history.back();
}, [backAction]);
useEffect(() => {
if (isInsideModal) return;
const prev = navigationGesture.enabled$.value;
navigationGesture.setEnabled(true);
return () => {
navigationGesture.setEnabled(prev);
};
}, [isInsideModal, navigationGesture]);
if (backAction) return backAction();
if (!backCoordinator.request('ui-back')) {
backCoordinator.request('ui-up');
}
}, [backAction, backCoordinator]);
const style = useMemo(() => ({ padding: 10, ...propsStyle }), [propsStyle]);
@@ -3,3 +3,4 @@ export { NavigationPanelCollections } from './sections/collections';
export { NavigationPanelFavorites } from './sections/favorites';
export { NavigationPanelOrganize } from './sections/organize';
export { NavigationPanelTags } from './sections/tags';
export { MobileNavigationVirtualScroller } from './virtual-scroller';
@@ -1,6 +1,5 @@
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { ToggleRightIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
@@ -91,9 +90,9 @@ export const CollapsibleSection = ({
);
return (
<Collapsible.Root
<div
data-collapsed={collapsed}
open={!collapsed}
data-state={collapsed ? 'closed' : 'open'}
data-testid={testId}
{...attrs}
>
@@ -105,12 +104,15 @@ export const CollapsibleSection = ({
data-testid={headerTestId}
className={headerClassName}
/>
<Collapsible.Content
data-testid="collapsible-section-content"
className={clsx(content, contentClassName)}
>
{children}
</Collapsible.Content>
</Collapsible.Root>
{collapsed ? null : (
<div
data-state="open"
data-testid="collapsible-section-content"
className={clsx(content, contentClassName)}
>
{children}
</div>
)}
</div>
);
};
@@ -0,0 +1,74 @@
import { MobileMenu } from '@affine/component';
import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import {
createContext,
Fragment,
type PropsWithChildren,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
type MenuHostContextValue = {
open(items: ReactNode): void;
close(): void;
};
const MenuHostContext = createContext<MenuHostContextValue | null>(null);
export const useMobileNavigationMenuHost = () => {
const context = useContext(MenuHostContext);
if (!context) {
throw new Error('MobileNavigationMenuHost is not mounted');
}
return context;
};
export const MobileNavigationMenuItems = ({
operations,
}: {
operations: NodeOperation[];
}) => (
<>
{[...operations]
.sort((a, b) => a.index - b.index)
.filter(operation => !operation.inline)
.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
</>
);
export const MobileNavigationMenuHost = ({ children }: PropsWithChildren) => {
const location = useLiveData(
useService(WorkbenchService).workbench.location$
);
const [items, setItems] = useState<ReactNode>(null);
const close = useCallback(() => setItems(null), []);
const value = useMemo<MenuHostContextValue>(
() => ({ open: setItems, close }),
[close]
);
useEffect(() => close(), [close, location.pathname]);
return (
<MenuHostContext.Provider value={value}>
{children}
<MobileMenu
rootOptions={{
open: items !== null,
onOpenChange: open => !open && close(),
}}
items={items}
>
<span hidden />
</MobileMenu>
</MenuHostContext.Provider>
);
};
@@ -18,8 +18,8 @@ import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { NavigationPanelTreeNode } from '../../tree/node';
import { NavigationPanelDocNode } from '../doc';
import {
useNavigationPanelCollectionNodeOperations,
useNavigationPanelCollectionNodeOperationsMenu,
NavigationPanelCollectionNodeMenu,
useNavigationPanelCollectionAddDoc,
} from './operations';
const CollectionIcon = () => <ViewLayersIcon />;
@@ -72,24 +72,26 @@ export const NavigationPanelCollectionNode = ({
});
}, [collection, workspaceDialogService]);
const collectionOperations = useNavigationPanelCollectionNodeOperationsMenu(
const handleAddDocToCollection = useNavigationPanelCollectionAddDoc(
collectionId,
handleOpenCollapsed,
handleEditCollection
handleOpenCollapsed
);
const { handleAddDocToCollection } =
useNavigationPanelCollectionNodeOperations(
const menuTarget = useMemo(
() => (
<NavigationPanelCollectionNodeMenu
collectionId={collectionId}
handleAddDocToCollection={handleAddDocToCollection}
onOpenEdit={handleEditCollection}
additionalOperations={additionalOperations}
/>
),
[
additionalOperations,
collectionId,
handleOpenCollapsed,
handleEditCollection
);
const finalOperations = useMemo(() => {
if (additionalOperations) {
return [...additionalOperations, ...collectionOperations];
}
return collectionOperations;
}, [additionalOperations, collectionOperations]);
handleAddDocToCollection,
handleEditCollection,
]
);
if (!collection) {
return null;
@@ -103,7 +105,7 @@ export const NavigationPanelCollectionNode = ({
setCollapsed={setCollapsed}
to={`/collection/${collection.id}`}
active={active}
operations={finalOperations}
menuTarget={menuTarget}
data-testid={`navigation-panel-collection-${collectionId}`}
>
<NavigationPanelCollectionNodeChildren
@@ -24,39 +24,22 @@ import {
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { MobileNavigationMenuItems } from '../../menu-host';
import { CollectionRenameSubMenu } from './dialog';
export const useNavigationPanelCollectionNodeOperations = (
export const useNavigationPanelCollectionAddDoc = (
collectionId: string,
onOpenCollapsed: () => void,
onOpenEdit: () => void
onOpenCollapsed: () => void
) => {
const t = useI18n();
const {
workbenchService,
workspaceService,
collectionService,
compatibleFavoriteItemsAdapter,
} = useServices({
WorkbenchService,
const { workspaceService, collectionService } = useServices({
WorkspaceService,
CollectionService,
CompatibleFavoriteItemsAdapter,
});
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const favorite = useLiveData(
useMemo(
() =>
compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
[collectionId, compatibleFavoriteItemsAdapter]
)
);
const { openConfirmModal } = useConfirmModal();
const createAndAddDocument = useCallback(() => {
const newDoc = createPage();
collectionService.addDocToCollection(collectionId, newDoc.id);
@@ -66,7 +49,41 @@ export const useNavigationPanelCollectionNodeOperations = (
});
onOpenCollapsed();
}, [collectionId, collectionService, createPage, onOpenCollapsed]);
return useCallback(() => {
openConfirmModal({
title: t['com.affine.collection.add-doc.confirm.title'](),
description: t['com.affine.collection.add-doc.confirm.description'](),
cancelText: t['Cancel'](),
confirmText: t['Confirm'](),
confirmButtonOptions: { variant: 'primary' },
onConfirm: createAndAddDocument,
});
}, [createAndAddDocument, openConfirmModal, t]);
};
export const useNavigationPanelCollectionNodeOperations = (
collectionId: string,
handleAddDocToCollection: () => void,
onOpenEdit: () => void
) => {
const t = useI18n();
const {
workbenchService,
collectionService,
compatibleFavoriteItemsAdapter,
} = useServices({
WorkbenchService,
CollectionService,
CompatibleFavoriteItemsAdapter,
});
const favorite = useLiveData(
useMemo(
() =>
compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
[collectionId, compatibleFavoriteItemsAdapter]
)
);
const handleToggleFavoriteCollection = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
track.$.navigationPanel.organize.toggleFavorite({
@@ -74,19 +91,6 @@ export const useNavigationPanelCollectionNodeOperations = (
});
}, [compatibleFavoriteItemsAdapter, collectionId]);
const handleAddDocToCollection = useCallback(() => {
openConfirmModal({
title: t['com.affine.collection.add-doc.confirm.title'](),
description: t['com.affine.collection.add-doc.confirm.description'](),
cancelText: t['Cancel'](),
confirmText: t['Confirm'](),
confirmButtonOptions: {
variant: 'primary',
},
onConfirm: createAndAddDocument,
});
}, [createAndAddDocument, openConfirmModal, t]);
const handleOpenInSplitView = useCallback(() => {
workbenchService.workbench.openCollection(collectionId, { at: 'beside' });
track.$.navigationPanel.organize.openInSplitView({
@@ -153,14 +157,14 @@ export const useNavigationPanelCollectionNodeOperations = (
export const useNavigationPanelCollectionNodeOperationsMenu = (
collectionId: string,
onOpenCollapsed: () => void,
handleAddDocToCollection: () => void,
onOpenEdit: () => void
): NodeOperation[] => {
const t = useI18n();
const {
favorite,
handleAddDocToCollection,
handleAddDocToCollection: addDocToCollection,
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
@@ -169,7 +173,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
handleRename,
} = useNavigationPanelCollectionNodeOperations(
collectionId,
onOpenCollapsed,
handleAddDocToCollection,
onOpenEdit
);
@@ -181,7 +185,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
view: (
<IconButton
size="16"
onClick={handleAddDocToCollection}
onClick={addDocToCollection}
tooltip={t[
'com.affine.rootAppSidebar.explorer.collection-add-tooltip'
]()}
@@ -209,10 +213,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
{
index: 99,
view: (
<MenuItem
prefixIcon={<PlusIcon />}
onClick={handleAddDocToCollection}
>
<MenuItem prefixIcon={<PlusIcon />} onClick={addDocToCollection}>
{t['New Page']()}
</MenuItem>
),
@@ -272,7 +273,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
],
[
favorite,
handleAddDocToCollection,
addDocToCollection,
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
@@ -283,3 +284,26 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
]
);
};
export const NavigationPanelCollectionNodeMenu = ({
collectionId,
handleAddDocToCollection,
onOpenEdit,
additionalOperations,
}: {
collectionId: string;
handleAddDocToCollection: () => void;
onOpenEdit: () => void;
additionalOperations?: NodeOperation[];
}) => {
const operations = useNavigationPanelCollectionNodeOperationsMenu(
collectionId,
handleAddDocToCollection,
onOpenEdit
);
const allOperations = useMemo(
() => [...(additionalOperations ?? []), ...operations],
[additionalOperations, operations]
);
return <MobileNavigationMenuItems operations={allOperations} />;
};
@@ -1,29 +1,18 @@
import { Loading } from '@affine/component';
import { Guard } from '@affine/core/components/guard';
import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import {
LiveData,
MANUALLY_STOP,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { MobileShellDataProjection } from '../../../../modules/navigation-projection';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { NavigationPanelTreeNode } from '../../tree/node';
import {
useNavigationPanelDocNodeOperations,
useNavigationPanelDocNodeOperationsMenu,
NavigationPanelDocNodeMenu,
useNavigationPanelDocNodeAddLinkedPage,
} from './operations';
import * as styles from './styles.css';
@@ -39,24 +28,13 @@ export const NavigationPanelDocNode = ({
parentPath: string[];
}) => {
const t = useI18n();
const {
docsSearchService,
docsService,
globalContextService,
docDisplayMetaService,
featureFlagService,
workspaceService,
} = useServices({
DocsSearchService,
WorkspaceService,
DocsService,
GlobalContextService,
const { docDisplayMetaService, featureFlagService } = useServices({
DocDisplayMetaService,
FeatureFlagService,
});
const navigationPanelService = useService(NavigationPanelService);
const active =
useLiveData(globalContextService.globalContext.docId.$) === docId;
const dataProjection = useService(MobileShellDataProjection);
const entry = useLiveData(dataProjection.entry$(docId));
const path = useMemo(
() => [...parentPath, `doc-${docId}`],
[parentPath, docId]
@@ -69,15 +47,12 @@ export const NavigationPanelDocNode = ({
[navigationPanelService, path]
);
const docRecord = useLiveData(docsService.list.doc$(docId));
const DocIcon = useLiveData(
docDisplayMetaService.icon$(docId, {
reference: isLinked,
})
);
const docTitle = useLiveData(docDisplayMetaService.title$(docId));
const isInTrash = useLiveData(docRecord?.trash$);
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_doc_icon.$
);
@@ -90,109 +65,72 @@ export const NavigationPanelDocNode = ({
);
const children = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
[docsSearchService, docId]
)
dataProjection.refsByDoc$.selector(refs => refs.get(docId))
);
useEffect(
() => (collapsed ? undefined : dataProjection.registerExpandedDoc(docId)),
[collapsed, dataProjection, docId]
);
const [referencesLoading, setReferencesLoading] = useState(true);
useLayoutEffect(() => {
if (collapsed) {
return;
}
const abortController = new AbortController();
const undoSync = workspaceService.workspace.engine.doc.addPriority(
docId,
10
);
const undoIndexer = docsSearchService.indexer.addPriority(docId, 10);
docsSearchService.indexer
.waitForDocCompleted(docId, abortController.signal)
.then(() => {
setReferencesLoading(false);
})
.catch(err => {
if (err !== MANUALLY_STOP) {
console.error(err);
}
});
return () => {
undoSync();
undoIndexer();
abortController.abort(MANUALLY_STOP);
};
}, [docId, docsSearchService, workspaceService, collapsed]);
const workspaceDialogService = useService(WorkspaceDialogService);
const option = useMemo(
() => ({
openInfoModal: () => workspaceDialogService.open('doc-info', { docId }),
openNodeCollapsed: () => setCollapsed(false),
}),
[docId, setCollapsed, workspaceDialogService]
const openNodeCollapsed = useCallback(
() => setCollapsed(false),
[setCollapsed]
);
const operations = useNavigationPanelDocNodeOperationsMenu(docId, option);
const { handleAddLinkedPage } = useNavigationPanelDocNodeOperations(
const handleAddLinkedPage = useNavigationPanelDocNodeAddLinkedPage(
docId,
option
openNodeCollapsed
);
const menuTarget = useMemo(
() => (
<NavigationPanelDocNodeMenu
docId={docId}
handleAddLinkedPage={handleAddLinkedPage}
additionalOperations={additionalOperations}
/>
),
[additionalOperations, docId, handleAddLinkedPage]
);
const finalOperations = useMemo(() => {
if (additionalOperations) {
return [...operations, ...additionalOperations];
}
return operations;
}, [additionalOperations, operations]);
if (isInTrash || !docRecord) {
if (entry?.trash !== false) {
return null;
}
return (
<NavigationPanelTreeNode
icon={Icon}
name={docTitle}
name={entry.title}
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/${docId}`}
active={active}
active={entry.active}
postfix={
referencesLoading &&
children === undefined &&
!collapsed && (
<div className={styles.loadingIcon}>
<Loading />
</div>
)
}
operations={finalOperations}
menuTarget={menuTarget}
data-testid={`navigation-panel-doc-${docId}`}
>
<Guard docId={docId} permission="Doc_Read">
{canRead =>
canRead
? children?.map((child, index) => (
<NavigationPanelDocNode
key={`${child.docId}-${index}`}
docId={child.docId}
isLinked
parentPath={path}
/>
))
: null
}
</Guard>
<Guard docId={docId} permission="Doc_Update">
{canEdit =>
canEdit ? (
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
{entry.canRead
? children?.map((child, index) => (
<NavigationPanelDocNode
key={`${child.docId}-${index}`}
docId={child.docId}
isLinked
parentPath={path}
/>
) : null
}
</Guard>
))
: null}
{entry.canUpdate ? (
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
) : null}
</NavigationPanelTreeNode>
);
};
@@ -29,35 +29,42 @@ import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DocFrameScope, DocInfoSheet } from '../../../doc-info';
import { MobileNavigationMenuItems } from '../../menu-host';
import { DocRenameSubMenu } from './dialog';
export const useNavigationPanelDocNodeOperations = (
export const useNavigationPanelDocNodeAddLinkedPage = (
docId: string,
options: {
openNodeCollapsed: () => void;
}
openNodeCollapsed: () => void
) => {
const t = useI18n();
const {
workbenchService,
workspaceService,
docsService,
compatibleFavoriteItemsAdapter,
} = useServices({
const { docsService, workspaceService } = useServices({
DocsService,
WorkbenchService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
});
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
return useAsyncCallback(async () => {
const newDoc = createPage();
await docsService.addLinkedDoc(docId, newDoc.id);
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
openNodeCollapsed();
}, [createPage, docId, docsService, openNodeCollapsed]);
};
export const useNavigationPanelDocNodeOperations = (docId: string) => {
const t = useI18n();
const { workbenchService, docsService, compatibleFavoriteItemsAdapter } =
useServices({
DocsService,
WorkbenchService,
CompatibleFavoriteItemsAdapter,
});
const { openConfirmModal } = useConfirmModal();
const docRecord = useLiveData(docsService.list.doc$(docId));
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const favorite = useLiveData(
useMemo(() => {
return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc');
@@ -112,15 +119,6 @@ export const useNavigationPanelDocNodeOperations = (
});
}, [docId, workbenchService.workbench]);
const handleAddLinkedPage = useAsyncCallback(async () => {
const newDoc = createPage();
// TODO: handle timeout & error
await docsService.addLinkedDoc(docId, newDoc.id);
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
options.openNodeCollapsed();
}, [createPage, docId, docsService, options]);
const handleToggleFavoriteDoc = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
track.$.navigationPanel.organize.toggleFavorite({
@@ -139,7 +137,6 @@ export const useNavigationPanelDocNodeOperations = (
return useMemo(
() => ({
favorite,
handleAddLinkedPage,
handleDuplicate,
handleToggleFavoriteDoc,
handleOpenInSplitView,
@@ -149,7 +146,6 @@ export const useNavigationPanelDocNodeOperations = (
}),
[
favorite,
handleAddLinkedPage,
handleDuplicate,
handleMoveToTrash,
handleOpenInNewTab,
@@ -163,26 +159,28 @@ export const useNavigationPanelDocNodeOperations = (
export const useNavigationPanelDocNodeOperationsMenu = (
docId: string,
options: {
openInfoModal: () => void;
openNodeCollapsed: () => void;
handleAddLinkedPage: () => void;
}
): NodeOperation[] => {
): {
operations: NodeOperation[];
handleAddLinkedPage: () => void;
} => {
const t = useI18n();
const { handleAddLinkedPage } = options;
const {
favorite,
handleAddLinkedPage,
handleDuplicate,
handleToggleFavoriteDoc,
handleOpenInNewTab,
handleMoveToTrash,
handleRename,
} = useNavigationPanelDocNodeOperations(docId, options);
} = useNavigationPanelDocNodeOperations(docId);
const docService = useService(DocsService);
const docRecord = useLiveData(docService.list.doc$(docId));
const title = useLiveData(docRecord?.title$);
return useMemo(
const operations = useMemo(
() => [
{
index: 10,
@@ -301,4 +299,28 @@ export const useNavigationPanelDocNodeOperationsMenu = (
title,
]
);
return useMemo(
() => ({ operations, handleAddLinkedPage }),
[handleAddLinkedPage, operations]
);
};
export const NavigationPanelDocNodeMenu = ({
docId,
handleAddLinkedPage,
additionalOperations,
}: {
docId: string;
handleAddLinkedPage: () => void;
additionalOperations?: NodeOperation[];
}) => {
const { operations } = useNavigationPanelDocNodeOperationsMenu(docId, {
handleAddLinkedPage,
});
const allOperations = useMemo(
() => [...operations, ...(additionalOperations ?? [])],
[additionalOperations, operations]
);
return <MobileNavigationMenuItems operations={allOperations} />;
};
@@ -1,6 +1,5 @@
import {
AnimatedFolderIcon,
IconButton,
MenuItem,
MenuSeparator,
MenuSub,
@@ -12,7 +11,6 @@ import type {
NodeOperation,
} from '@affine/core/desktop/components/navigation-panel';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import {
@@ -27,16 +25,16 @@ import {
FolderIcon,
LayerIcon,
PageIcon,
PlusIcon,
PlusThickIcon,
RemoveFolderIcon,
TagsIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { difference } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { type ReactNode, useCallback, useMemo } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { MobileNavigationMenuItems } from '../../menu-host';
import { NavigationPanelTreeNode } from '../../tree/node';
import { NavigationPanelCollectionNode } from '../collection';
import { NavigationPanelDocNode } from '../doc';
@@ -129,169 +127,39 @@ const NavigationPanelFolderIcon: NavigationPanelTreeNodeIcon = ({
/>
);
const NavigationPanelFolderNodeFolder = ({
node,
operations: additionalOperations,
parentPath,
const NavigationPanelFolderMenu = ({
nodeId,
name,
handleDelete,
handleRename,
handleCreateSubfolder,
handleAddToFolder,
createSubTipRenderer,
additionalOperations,
}: {
node: FolderNode;
operations?: NodeOperation[];
parentPath: string[];
nodeId: string;
name: string;
handleDelete: () => void;
handleRename: (name: string) => void;
handleCreateSubfolder: (name: string) => void;
handleAddToFolder: (type: 'doc' | 'collection' | 'tag') => void;
createSubTipRenderer: (props: { input: string }) => ReactNode;
additionalOperations?: NodeOperation[];
}) => {
const t = useI18n();
const { workspaceService, featureFlagService, workspaceDialogService } =
useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
WorkspaceDialogService,
});
const name = useLiveData(node.name$);
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_folder_icon.$
);
const navigationPanelService = useService(NavigationPanelService);
const path = useMemo(
() => [...parentPath, `folder-${node.id}`],
[parentPath, node.id]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const handleDelete = useCallback(() => {
node.delete();
track.$.navigationPanel.organize.deleteOrganizeItem({
type: 'folder',
});
notify.success({
title: t['com.affine.rootAppSidebar.organize.delete.notify-title']({
name,
}),
message: t['com.affine.rootAppSidebar.organize.delete.notify-message'](),
});
}, [name, node, t]);
const children = useLiveData(node.sortedChildren$);
const handleRename = useCallback(
(newName: string) => {
node.rename(newName);
},
[node]
);
const handleNewDoc = useCallback(() => {
const newDoc = createPage();
node.createLink('doc', newDoc.id, node.indexAt('before'));
track.$.navigationPanel.folders.createDoc();
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: 'doc',
});
setCollapsed(false);
}, [createPage, node, setCollapsed]);
const handleCreateSubfolder = useCallback(
(name: string) => {
node.createFolder(name, node.indexAt('before'));
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setCollapsed(false);
},
[node, setCollapsed]
);
const handleAddToFolder = useCallback(
(type: 'doc' | 'collection' | 'tag') => {
const initialIds = children
.filter(node => node.type$.value === type)
.map(node => node.data$.value)
.filter(Boolean) as string[];
const selector =
type === 'doc'
? 'doc-selector'
: type === 'collection'
? 'collection-selector'
: 'tag-selector';
workspaceDialogService.open(
selector,
{
init: initialIds,
},
selectedIds => {
if (selectedIds === undefined) {
return;
}
const newItemIds = difference(selectedIds, initialIds);
const removedItemIds = difference(initialIds, selectedIds);
const removedItems = children.filter(
node =>
!!node.data$.value && removedItemIds.includes(node.data$.value)
);
newItemIds.forEach(id => {
node.createLink(type, id, node.indexAt('after'));
});
removedItems.forEach(node => node.delete());
const updated = newItemIds.length + removedItems.length;
updated && setCollapsed(false);
}
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: type,
});
},
[children, node, setCollapsed, workspaceDialogService]
);
const createSubTipRenderer = useCallback(
({ input }: { input: string }) => {
return <FolderCreateTip input={input} parentName={name} />;
},
[name]
);
const folderOperations = useMemo(() => {
return [
{
index: 0,
inline: true,
view: (
<IconButton
size="16"
onClick={handleNewDoc}
tooltip={t[
'com.affine.rootAppSidebar.explorer.organize-add-tooltip'
]()}
>
<PlusIcon />
</IconButton>
),
},
const operations = useMemo(
() => [
{
index: 98,
view: (
<FolderRenameSubMenu
initialName={name}
onConfirm={handleRename}
menuProps={{
triggerOptions: { 'data-testid': 'rename-folder' },
}}
menuProps={{ triggerOptions: { 'data-testid': 'rename-folder' } }}
/>
),
},
{
index: 99,
view: <MenuSeparator />,
},
{ index: 99, view: <MenuSeparator /> },
{
index: 100,
view: (
@@ -315,9 +183,7 @@ const NavigationPanelFolderNodeFolder = ({
index: 102,
view: (
<MenuSub
triggerOptions={{
prefixIcon: <PlusThickIcon />,
}}
triggerOptions={{ prefixIcon: <PlusThickIcon /> }}
items={
<>
<MenuItem
@@ -327,14 +193,14 @@ const NavigationPanelFolderNodeFolder = ({
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
</MenuItem>
<MenuItem
onClick={() => handleAddToFolder('tag')}
prefixIcon={<TagsIcon />}
onClick={() => handleAddToFolder('tag')}
>
{t['com.affine.rootAppSidebar.organize.folder.add-tags']()}
</MenuItem>
<MenuItem
onClick={() => handleAddToFolder('collection')}
prefixIcon={<LayerIcon />}
onClick={() => handleAddToFolder('collection')}
>
{t[
'com.affine.rootAppSidebar.organize.folder.add-collections'
@@ -347,21 +213,16 @@ const NavigationPanelFolderNodeFolder = ({
</MenuSub>
),
},
{
index: 200,
view: node.id ? <FavoriteFolderOperation id={node.id} /> : null,
},
{
index: 9999,
view: <MenuSeparator key="menu-separator" />,
view: nodeId ? <FavoriteFolderOperation id={nodeId} /> : null,
},
{ index: 9999, view: <MenuSeparator /> },
{
index: 10000,
view: (
<MenuItem
type={'danger'}
type="danger"
prefixIcon={<DeleteIcon />}
onClick={handleDelete}
>
@@ -369,25 +230,182 @@ const NavigationPanelFolderNodeFolder = ({
</MenuItem>
),
},
];
}, [
createSubTipRenderer,
handleAddToFolder,
handleCreateSubfolder,
handleDelete,
handleNewDoc,
handleRename,
name,
node.id,
t,
]);
],
[
createSubTipRenderer,
handleAddToFolder,
handleCreateSubfolder,
handleDelete,
handleRename,
name,
nodeId,
t,
]
);
return (
<MobileNavigationMenuItems
operations={[...(additionalOperations ?? []), ...operations]}
/>
);
};
const finalOperations = useMemo(() => {
if (additionalOperations) {
return [...additionalOperations, ...folderOperations];
}
return folderOperations;
}, [additionalOperations, folderOperations]);
export const NavigationPanelFolderNodeMenu = ({
nodeId,
additionalOperations,
}: {
nodeId: string;
additionalOperations?: NodeOperation[];
}) => {
const t = useI18n();
const { organizeService, workspaceDialogService } = useServices({
OrganizeService,
WorkspaceDialogService,
});
const node = useLiveData(organizeService.folderTree.folderNode$(nodeId));
const name = useLiveData(node?.name$) ?? '';
const children = useLiveData(node?.sortedChildren$);
const handleDelete = useCallback(() => {
if (!node) return;
node.delete();
track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'folder' });
notify.success({
title: t['com.affine.rootAppSidebar.organize.delete.notify-title']({
name,
}),
message: t['com.affine.rootAppSidebar.organize.delete.notify-message'](),
});
}, [name, node, t]);
const handleRename = useCallback(
(newName: string) => node?.rename(newName),
[node]
);
const handleCreateSubfolder = useCallback(
(newName: string) => {
if (!node) return;
node.createFolder(newName, node.indexAt('before'));
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
},
[node]
);
const handleAddToFolder = useCallback(
(type: 'doc' | 'collection' | 'tag') => {
if (!node) return;
const currentChildren = children ?? [];
const initialIds = currentChildren
.filter(child => child.type$.value === type)
.map(child => child.data$.value)
.filter(Boolean) as string[];
const selector =
type === 'doc'
? 'doc-selector'
: type === 'collection'
? 'collection-selector'
: 'tag-selector';
workspaceDialogService.open(
selector,
{ init: initialIds },
selectedIds => {
if (selectedIds === undefined) return;
const newItemIds = difference(selectedIds, initialIds);
const removedItemIds = difference(initialIds, selectedIds);
newItemIds.forEach(id =>
node.createLink(type, id, node.indexAt('after'))
);
currentChildren
.filter(
child =>
!!child.data$.value &&
removedItemIds.includes(child.data$.value)
)
.forEach(child => child.delete());
}
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: type,
});
},
[children, node, workspaceDialogService]
);
const createSubTipRenderer = useCallback(
({ input }: { input: string }) => (
<FolderCreateTip input={input} parentName={name} />
),
[name]
);
if (!node) return null;
return (
<NavigationPanelFolderMenu
nodeId={nodeId}
name={name}
handleDelete={handleDelete}
handleRename={handleRename}
handleCreateSubfolder={handleCreateSubfolder}
handleAddToFolder={handleAddToFolder}
createSubTipRenderer={createSubTipRenderer}
additionalOperations={additionalOperations}
/>
);
};
const NavigationPanelFolderNodeFolder = ({
node,
operations: additionalOperations,
parentPath,
}: {
node: FolderNode;
operations?: NodeOperation[];
parentPath: string[];
}) => {
const t = useI18n();
const { workspaceService, featureFlagService } = useServices({
WorkspaceService,
FeatureFlagService,
});
const name = useLiveData(node.name$);
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_folder_icon.$
);
const navigationPanelService = useService(NavigationPanelService);
const path = useMemo(
() => [...parentPath, `folder-${node.id}`],
[parentPath, node.id]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const children = useLiveData(node.sortedChildren$);
const handleNewDoc = useCallback(() => {
const newDoc = createPage();
node.createLink('doc', newDoc.id, node.indexAt('before'));
track.$.navigationPanel.folders.createDoc();
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: 'doc',
});
setCollapsed(false);
}, [createPage, node, setCollapsed]);
const menuTarget = useMemo(
() => (
<NavigationPanelFolderNodeMenu
nodeId={node.id ?? ''}
additionalOperations={additionalOperations}
/>
),
[additionalOperations, node.id]
);
const childrenOperations = useCallback(
(type: string, node: FolderNode) => {
@@ -432,7 +450,7 @@ const NavigationPanelFolderNodeFolder = ({
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={handleCollapsedChange}
operations={finalOperations}
menuTarget={menuTarget}
data-testid={`navigation-panel-folder-${node.id}`}
aria-label={name}
data-role="navigation-panel-folder"
@@ -12,8 +12,8 @@ import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { NavigationPanelTreeNode } from '../../tree/node';
import { NavigationPanelDocNode } from '../doc';
import {
useNavigationPanelTagNodeOperations,
useNavigationPanelTagNodeOperationsMenu,
NavigationPanelTagNodeMenu,
useNavigationPanelTagNodeNewDoc,
} from './operations';
import * as styles from './styles.css';
@@ -67,21 +67,24 @@ export const NavigationPanelTagNode = ({
[tagColor]
);
const option = useMemo(
() => ({
openNodeCollapsed: () => setCollapsed(false),
}),
const openNodeCollapsed = useCallback(
() => setCollapsed(false),
[setCollapsed]
);
const operations = useNavigationPanelTagNodeOperationsMenu(tagId, option);
const { handleNewDoc } = useNavigationPanelTagNodeOperations(tagId, option);
const finalOperations = useMemo(() => {
if (additionalOperations) {
return [...operations, ...additionalOperations];
}
return operations;
}, [additionalOperations, operations]);
const handleNewDoc = useNavigationPanelTagNodeNewDoc(
tagId,
openNodeCollapsed
);
const menuTarget = useMemo(
() => (
<NavigationPanelTagNodeMenu
tagId={tagId}
handleNewDoc={handleNewDoc}
additionalOperations={additionalOperations}
/>
),
[additionalOperations, handleNewDoc, tagId]
);
if (!tagRecord) {
return null;
@@ -95,7 +98,7 @@ export const NavigationPanelTagNode = ({
setCollapsed={setCollapsed}
to={`/tag/${tagId}`}
active={active}
operations={finalOperations}
menuTarget={menuTarget}
data-testid={`navigation-panel-tag-${tagId}`}
aria-label={tagName}
data-role="navigation-panel-tag"
@@ -26,27 +26,47 @@ import {
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { MobileNavigationMenuItems } from '../../menu-host';
import { TagRenameSubMenu } from './dialog';
export const useNavigationPanelTagNodeNewDoc = (
tagId: string,
openNodeCollapsed: () => void
) => {
const { workspaceService, tagService } = useServices({
WorkspaceService,
TagService,
});
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
return useCallback(() => {
if (!tagRecord) return;
const newDoc = createPage();
tagRecord.tag(newDoc.id);
track.$.navigationPanel.tags.createDoc();
openNodeCollapsed();
}, [createPage, openNodeCollapsed, tagRecord]);
};
export const useNavigationPanelTagNodeOperations = (
tagId: string,
{
openNodeCollapsed,
handleNewDoc,
}: {
openNodeCollapsed: () => void;
handleNewDoc: () => void;
}
) => {
const t = useI18n();
const {
workbenchService,
workspaceService,
tagService,
favoriteService,
workspaceDialogService,
globalCacheService,
} = useServices({
WorkbenchService,
WorkspaceService,
TagService,
DocsService,
FavoriteService,
@@ -60,19 +80,6 @@ export const useNavigationPanelTagNodeOperations = (
);
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const handleNewDoc = useCallback(() => {
if (tagRecord) {
const newDoc = createPage();
tagRecord?.tag(newDoc.id);
track.$.navigationPanel.tags.createDoc();
openNodeCollapsed();
}
}, [createPage, openNodeCollapsed, tagRecord]);
const handleMoveToTrash = useCallback(() => {
tagService.tagList.deleteTag(tagId);
track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'tag' });
@@ -214,7 +221,7 @@ export const useNavigationPanelTagNodeOperations = (
export const useNavigationPanelTagNodeOperationsMenu = (
tagId: string,
option: {
openNodeCollapsed: () => void;
handleNewDoc: () => void;
}
): NodeOperation[] => {
const t = useI18n();
@@ -319,3 +326,22 @@ export const useNavigationPanelTagNodeOperationsMenu = (
]
);
};
export const NavigationPanelTagNodeMenu = ({
tagId,
handleNewDoc,
additionalOperations,
}: {
tagId: string;
handleNewDoc: () => void;
additionalOperations?: NodeOperation[];
}) => {
const operations = useNavigationPanelTagNodeOperationsMenu(tagId, {
handleNewDoc,
});
const allOperations = useMemo(
() => [...operations, ...(additionalOperations ?? [])],
[additionalOperations, operations]
);
return <MobileNavigationMenuItems operations={allOperations} />;
};
@@ -1,4 +1,3 @@
import { MobileMenu } from '@affine/component';
import {
type BaseNavigationPanelTreeNodeProps,
NavigationPanelTreeContext,
@@ -6,21 +5,32 @@ import {
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { extractEmojiIcon } from '@affine/core/utils';
import { ArrowDownSmallIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import {
Fragment,
type AriaAttributes,
type AriaRole,
type ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { SwipeMenu } from '../../swipe-menu';
import {
MobileNavigationMenuItems,
useMobileNavigationMenuHost,
} from '../menu-host';
import * as styles from './node.css';
interface NavigationPanelTreeNodeProps extends BaseNavigationPanelTreeNodeProps {}
interface NavigationPanelTreeNodeProps extends BaseNavigationPanelTreeNodeProps {
menuTarget?: ReactNode;
role?: AriaRole;
'aria-label'?: string;
'aria-level'?: number;
'aria-expanded'?: AriaAttributes['aria-expanded'];
'data-role'?: string;
}
const EMPTY_OPERATIONS: BaseNavigationPanelTreeNodeProps['operations'] = [];
@@ -39,6 +49,7 @@ export const NavigationPanelTreeNode = ({
postfix,
childrenOperations = EMPTY_OPERATIONS,
childrenPlaceholder,
menuTarget,
linkComponent: LinkComponent = WorkbenchLink,
...otherProps
}: NavigationPanelTreeNodeProps) => {
@@ -47,8 +58,7 @@ export const NavigationPanelTreeNode = ({
// If no onClick or to is provided, clicking on the node will toggle the collapse state
const clickForCollapse = !onClick && !to && !disabled;
const [childCount, setChildCount] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuHost = useMobileNavigationMenuHost();
const { emoji, name } = useMemo(() => {
if (!extractEmojiAsIcon || !rawName) {
@@ -71,6 +81,13 @@ export const NavigationPanelTreeNode = ({
inlineOperations: sorted.filter(({ inline }) => !!inline),
};
}, [operations]);
const openMenu = useCallback(() => {
if (menuTarget || menuOperations.length > 0) {
menuHost.open(
menuTarget ?? <MobileNavigationMenuItems operations={menuOperations} />
);
}
}, [menuHost, menuOperations, menuTarget]);
const contextValue = useMemo(() => {
return {
@@ -114,22 +131,17 @@ export const NavigationPanelTreeNode = ({
data-disabled={disabled}
>
<div className={styles.itemMain}>
{menuOperations.length > 0 ? (
{menuTarget || menuOperations.length > 0 ? (
<div
onClick={e => {
// prevent jump to page
e.preventDefault();
openMenu();
}}
className={styles.iconContainer}
data-testid="menu-trigger"
>
<MobileMenu
items={menuOperations.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
>
<div className={styles.iconContainer} data-testid="menu-trigger">
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
</div>
</MobileMenu>
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
</div>
) : (
<div className={styles.iconContainer}>
@@ -157,29 +169,23 @@ export const NavigationPanelTreeNode = ({
);
return (
<Collapsible.Root
open={!collapsed}
onOpenChange={setCollapsed}
<div
data-state={collapsed ? 'closed' : 'open'}
style={assignInlineVars({
[styles.levelIndent]: `${level * 20}px`,
})}
ref={rootRef}
{...otherProps}
>
<SwipeMenu
onExecute={useCallback(() => setMenuOpen(true), [])}
onExecute={openMenu}
menu={
<MobileMenu
rootOptions={useMemo(
() => ({ open: menuOpen, onOpenChange: setMenuOpen }),
[menuOpen]
)}
items={menuOperations.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
<button
type="button"
onClick={openMenu}
data-testid="swipe-menu-trigger"
>
<MoreHorizontalIcon fontSize={24} />
</MobileMenu>
</button>
}
>
<div className={styles.contentContainer} data-open={!collapsed}>
@@ -196,15 +202,17 @@ export const NavigationPanelTreeNode = ({
)}
</div>
</SwipeMenu>
<Collapsible.Content>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}>
{childCount === 0 && !collapsed && childrenPlaceholder}
{collapsed ? null : (
<div data-state="open">
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}>
{childCount === 0 && childrenPlaceholder}
</div>
<NavigationPanelTreeContext.Provider value={contextValue}>
{children}
</NavigationPanelTreeContext.Provider>
</div>
<NavigationPanelTreeContext.Provider value={contextValue}>
{collapsed ? null : children}
</NavigationPanelTreeContext.Provider>
</Collapsible.Content>
</Collapsible.Root>
)}
</div>
);
};
@@ -0,0 +1,927 @@
import { MenuItem, SafeArea, usePromptModal } from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { NavigationPanelTreeRoot } from '@affine/core/desktop/components/navigation-panel';
import { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FavoriteService } from '@affine/core/modules/favorite';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { OrganizeService } from '@affine/core/modules/organize';
import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
AddCollectionIcon,
AddOrganizeIcon,
AddTagIcon,
FolderIcon,
PageIcon,
RemoveFolderIcon,
ToggleRightIcon,
ViewLayersIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import {
forwardRef,
type HTMLAttributes,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type Components,
type ListRange,
Virtuoso,
type VirtuosoHandle,
} from 'react-virtuoso';
import { MobileBackCoordinator } from '../../../modules/back-coordinator';
import {
MobileNavigationProjection,
type MobileNavigationRow,
MobileShellDataProjection,
type MobileShellNavigationEntry,
} from '../../../modules/navigation-projection';
import { HomeHeader } from '../../../views/home-header';
import { RecentDocs } from '../../../views/recent-docs';
import { AddItemPlaceholder } from '../layouts/add-item-placeholder';
import {
NavigationPanelCollectionNodeMenu,
useNavigationPanelCollectionAddDoc,
} from '../nodes/collection/operations';
import {
NavigationPanelDocNodeMenu,
useNavigationPanelDocNodeAddLinkedPage,
} from '../nodes/doc/operations';
import { NavigationPanelFolderNodeMenu } from '../nodes/folder';
import { FolderCreateTip, FolderRenameDialog } from '../nodes/folder/dialog';
import { TagRenameDialog } from '../nodes/tag/dialog';
import {
NavigationPanelTagNodeMenu,
useNavigationPanelTagNodeNewDoc,
} from '../nodes/tag/operations';
import { NavigationPanelTreeNode } from '../tree/node';
import * as styles from './styles.css';
const sectionTitles = {
favorites: 'com.affine.rootAppSidebar.favorites',
organize: 'com.affine.rootAppSidebar.organize',
collections: 'com.affine.rootAppSidebar.collections',
tags: 'com.affine.rootAppSidebar.tags',
} as const;
const Header = () => (
<>
<HomeHeader />
<RecentDocs />
</>
);
const Footer = () => <SafeArea bottom />;
const List = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
function NavigationList(props, ref) {
return <div {...props} ref={ref} role="tree" className={styles.list} />;
}
);
const virtuosoComponents: Components<MobileNavigationRow> = {
Footer,
Header,
List,
};
const afterNextPaint = (callback: () => void) => {
let nextFrame = 0;
const frame = requestAnimationFrame(() => {
nextFrame = requestAnimationFrame(callback);
});
return () => {
cancelAnimationFrame(frame);
cancelAnimationFrame(nextFrame);
};
};
const rowPath = (rowId: string) =>
rowId.split('/').map(segment => {
const decoded = decodeURIComponent(segment);
const separator = decoded.indexOf(':');
return separator === -1
? decoded
: `${decoded.slice(0, separator)}-${decoded.slice(separator + 1)}`;
});
const rowIndent = (depth: number) => Math.max(0, depth - 1) * 20;
const RowIcon = ({ entry }: { entry: MobileShellNavigationEntry }) => {
if (entry.kind === 'collection') return <ViewLayersIcon />;
if (entry.kind === 'folder') return <FolderIcon />;
if (entry.kind === 'tag') {
return (
<span
className={styles.tagIcon}
style={{ backgroundColor: entry.color }}
data-testid="navigation-panel-tag-icon-dot"
/>
);
}
return <PageIcon />;
};
const EntityRowShell = ({
row,
entry,
menuTarget,
toggle,
}: {
row: MobileNavigationRow;
entry: MobileShellNavigationEntry;
menuTarget?: ReactNode;
toggle: () => void;
}) => {
const to =
entry.kind === 'doc'
? `/${entry.id}`
: entry.kind === 'collection'
? `/collection/${entry.id}`
: entry.kind === 'tag'
? `/tag/${entry.id}`
: undefined;
const Icon = useCallback(() => <RowIcon entry={entry} />, [entry]);
return (
<div
className={styles.row}
style={{ paddingLeft: rowIndent(row.depth) }}
data-navigation-row-id={row.id}
tabIndex={-1}
>
<NavigationPanelTreeRoot>
<NavigationPanelTreeNode
icon={Icon}
name={entry.name}
to={to}
active={entry.active}
collapsed={!row.expanded}
setCollapsed={toggle}
menuTarget={menuTarget}
data-testid={`navigation-panel-${entry.kind}-${entry.id}`}
data-role={`navigation-panel-${entry.kind}`}
role="treeitem"
aria-label={entry.name}
aria-level={row.depth + 1}
aria-expanded={row.expandable ? row.expanded : undefined}
/>
</NavigationPanelTreeRoot>
</div>
);
};
const DocRow = ({
row,
entry,
toggle,
}: {
row: MobileNavigationRow;
entry: MobileShellNavigationEntry;
toggle: () => void;
}) => {
const expand = useCallback(() => {
if (!row.expanded) toggle();
}, [row.expanded, toggle]);
const handleAddLinkedPage = useNavigationPanelDocNodeAddLinkedPage(
entry.id,
expand
);
const menuTarget = useMemo(
() => (
<NavigationPanelDocNodeMenu
docId={entry.id}
handleAddLinkedPage={handleAddLinkedPage}
additionalOperations={
row.relationId
? [
{
index: 999,
view: (
<RemoveFromFolderMenuItem relationId={row.relationId} />
),
},
]
: undefined
}
/>
),
[entry.id, handleAddLinkedPage, row.relationId]
);
return (
<EntityRowShell
row={row}
entry={entry}
toggle={toggle}
menuTarget={menuTarget}
/>
);
};
const CollectionRow = ({
row,
entry,
toggle,
}: {
row: MobileNavigationRow;
entry: MobileShellNavigationEntry;
toggle: () => void;
}) => {
const { collectionService, workspaceDialogService } = useServices({
CollectionService,
WorkspaceDialogService,
});
const expand = useCallback(() => {
if (!row.expanded) toggle();
}, [row.expanded, toggle]);
const handleAdd = useNavigationPanelCollectionAddDoc(entry.id, expand);
const handleEdit = useCallback(() => {
const collection = collectionService.collection$(entry.id).value;
if (collection) {
workspaceDialogService.open('collection-editor', {
collectionId: entry.id,
});
}
}, [collectionService, entry.id, workspaceDialogService]);
const menuTarget = useMemo(
() => (
<NavigationPanelCollectionNodeMenu
collectionId={entry.id}
handleAddDocToCollection={handleAdd}
onOpenEdit={handleEdit}
additionalOperations={
row.relationId
? [
{
index: 999,
view: (
<RemoveFromFolderMenuItem relationId={row.relationId} />
),
},
]
: undefined
}
/>
),
[entry.id, handleAdd, handleEdit, row.relationId]
);
return (
<EntityRowShell
row={row}
entry={entry}
toggle={toggle}
menuTarget={menuTarget}
/>
);
};
const TagRow = ({
row,
entry,
toggle,
}: {
row: MobileNavigationRow;
entry: MobileShellNavigationEntry;
toggle: () => void;
}) => {
const expand = useCallback(() => {
if (!row.expanded) toggle();
}, [row.expanded, toggle]);
const handleNewDoc = useNavigationPanelTagNodeNewDoc(entry.id, expand);
const menuTarget = useMemo(
() => (
<NavigationPanelTagNodeMenu
tagId={entry.id}
handleNewDoc={handleNewDoc}
additionalOperations={
row.relationId
? [
{
index: 999,
view: (
<RemoveFromFolderMenuItem relationId={row.relationId} />
),
},
]
: undefined
}
/>
),
[entry.id, handleNewDoc, row.relationId]
);
return (
<EntityRowShell
row={row}
entry={entry}
toggle={toggle}
menuTarget={menuTarget}
/>
);
};
const RemoveFromFolderMenuItem = ({ relationId }: { relationId: string }) => {
const t = useI18n();
const organizeService = useService(OrganizeService);
return (
<MenuItem
type="danger"
prefixIcon={<RemoveFolderIcon />}
onClick={() =>
organizeService.folderTree.folderNode$(relationId).value?.delete()
}
>
{t['com.affine.rootAppSidebar.organize.delete-from-folder']()}
</MenuItem>
);
};
const useFolderNewDoc = (folderId: string, onCreated?: () => void) => {
const { organizeService, workspaceService } = useServices({
OrganizeService,
WorkspaceService,
});
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
return useCallback(() => {
const folder = organizeService.folderTree.folderNode$(folderId).value;
if (!folder) return;
const doc = createPage();
folder.createLink('doc', doc.id, folder.indexAt('before'));
track.$.navigationPanel.folders.createDoc();
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: 'doc',
});
onCreated?.();
}, [createPage, folderId, onCreated, organizeService.folderTree]);
};
const FolderRow = ({
row,
entry,
toggle,
}: {
row: MobileNavigationRow;
entry: MobileShellNavigationEntry;
toggle: () => void;
}) => {
const t = useI18n();
const expand = useCallback(() => {
if (!row.expanded) toggle();
}, [row.expanded, toggle]);
const handleNewDoc = useFolderNewDoc(entry.id, expand);
const menuTarget = useMemo(
() => (
<NavigationPanelFolderNodeMenu
nodeId={entry.id}
additionalOperations={[
{
index: 0,
view: (
<MenuItem prefixIcon={<PageIcon />} onClick={handleNewDoc}>
{t['com.affine.rootAppSidebar.organize.folder.new-doc']()}
</MenuItem>
),
},
]}
/>
),
[entry.id, handleNewDoc, t]
);
return (
<EntityRowShell
row={row}
entry={entry}
toggle={toggle}
menuTarget={menuTarget}
/>
);
};
const EntityRow = ({
row,
entry,
toggle,
}: {
row: MobileNavigationRow;
entry: MobileShellNavigationEntry;
toggle: () => void;
}) => {
if (entry.kind === 'doc') {
return <DocRow row={row} entry={entry} toggle={toggle} />;
}
if (entry.kind === 'collection') {
return <CollectionRow row={row} entry={entry} toggle={toggle} />;
}
if (entry.kind === 'tag') {
return <TagRow row={row} entry={entry} toggle={toggle} />;
}
return <FolderRow row={row} entry={entry} toggle={toggle} />;
};
const FolderNewDocAction = ({ row }: { row: MobileNavigationRow }) => {
const t = useI18n();
const handleCreate = useFolderNewDoc(row.entityId ?? '');
return (
<div
className={styles.row}
style={{ paddingLeft: rowIndent(row.depth) }}
data-navigation-row-id={row.id}
role="treeitem"
aria-level={row.depth + 1}
tabIndex={-1}
>
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.organize.folder.new-doc']()}
onClick={handleCreate}
data-testid="new-folder-in-folder-button"
/>
</div>
);
};
const DocNewLinkedActionEnabled = ({
row,
docId,
}: {
row: MobileNavigationRow;
docId: string;
}) => {
const t = useI18n();
const keepExpanded = useCallback(() => {}, []);
const handleCreate = useNavigationPanelDocNodeAddLinkedPage(
docId,
keepExpanded
);
return (
<div
className={styles.row}
style={{ paddingLeft: rowIndent(row.depth) }}
data-navigation-row-id={row.id}
role="treeitem"
aria-level={row.depth + 1}
tabIndex={-1}
>
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleCreate}
data-testid="navigation-panel-doc-add-linked-page"
/>
</div>
);
};
const DocNewLinkedAction = ({ row }: { row: MobileNavigationRow }) => {
const dataProjection = useService(MobileShellDataProjection);
const docId = row.entityId ?? '';
const entry = useLiveData(dataProjection.navigationEntry$('doc', docId));
return entry?.canUpdate === true ? (
<DocNewLinkedActionEnabled row={row} docId={docId} />
) : null;
};
const CollectionNewDocAction = ({ row }: { row: MobileNavigationRow }) => {
const t = useI18n();
const keepExpanded = useCallback(() => {}, []);
const handleCreate = useNavigationPanelCollectionAddDoc(
row.entityId ?? '',
keepExpanded
);
return (
<div
className={styles.row}
style={{ paddingLeft: rowIndent(row.depth) }}
data-navigation-row-id={row.id}
role="treeitem"
aria-level={row.depth + 1}
tabIndex={-1}
>
<AddItemPlaceholder
label={t['New Page']()}
onClick={handleCreate}
data-testid="navigation-panel-collection-add-page"
/>
</div>
);
};
const TagNewDocAction = ({ row }: { row: MobileNavigationRow }) => {
const t = useI18n();
const keepExpanded = useCallback(() => {}, []);
const handleCreate = useNavigationPanelTagNodeNewDoc(
row.entityId ?? '',
keepExpanded
);
return (
<div
className={styles.row}
style={{ paddingLeft: rowIndent(row.depth) }}
data-navigation-row-id={row.id}
role="treeitem"
aria-level={row.depth + 1}
tabIndex={-1}
>
<AddItemPlaceholder
label={t['New Page']()}
onClick={handleCreate}
data-testid="navigation-panel-tag-add-page"
/>
</div>
);
};
const ProjectedEntityRow = ({
row,
toggle,
}: {
row: MobileNavigationRow;
toggle: () => void;
}) => {
const dataProjection = useService(MobileShellDataProjection);
const entry = useLiveData(
dataProjection.navigationEntry$(
row.kind as MobileShellNavigationEntry['kind'],
row.entityId ?? ''
)
);
const t = useI18n();
if (!entry) return null;
if (entry.kind === 'doc' && entry.canRead !== true) {
return (
<div
className={styles.permission}
style={{ paddingLeft: rowIndent(row.depth) }}
data-navigation-row-id={row.id}
role="treeitem"
aria-level={row.depth + 1}
tabIndex={-1}
>
{entry.canRead === false
? t['com.affine.no-permission']()
: t['Loading']()}
</div>
);
}
return <EntityRow row={row} entry={entry} toggle={toggle} />;
};
const FavoritesAction = () => {
const t = useI18n();
const { favoriteService, workspaceService } = useServices({
FavoriteService,
WorkspaceService,
});
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const handleCreate = useCallback(() => {
const doc = createPage();
favoriteService.favoriteList.add(
'doc',
doc.id,
favoriteService.favoriteList.indexAt('before')
);
}, [createPage, favoriteService.favoriteList]);
return (
<AddItemPlaceholder
data-testid="navigation-panel-bar-add-favorite-button"
label={t['New Page']()}
onClick={handleCreate}
/>
);
};
const OrganizeAction = () => {
const t = useI18n();
const organizeService = useService(OrganizeService);
const [open, setOpen] = useState(false);
const root = organizeService.folderTree.rootFolder;
const handleCreate = useCallback(
(name: string) => {
const id = root.createFolder(name, root.indexAt('before'));
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
return id;
},
[root]
);
return (
<>
<AddItemPlaceholder
icon={<AddOrganizeIcon />}
data-testid="navigation-panel-bar-add-organize-button"
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
onClick={() => setOpen(true)}
/>
<FolderRenameDialog
open={open}
onOpenChange={setOpen}
onConfirm={handleCreate}
descRenderer={FolderCreateTip}
/>
</>
);
};
const CollectionsAction = () => {
const t = useI18n();
const { collectionService, workbenchService } = useServices({
CollectionService,
WorkbenchService,
});
const { openPromptModal } = usePromptModal();
const handleCreate = useCallback(() => {
openPromptModal({
title: t['com.affine.editCollection.saveCollection'](),
label: t['com.affine.editCollectionName.name'](),
inputOptions: {
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
},
confirmText: t['com.affine.editCollection.save'](),
cancelText: t['com.affine.editCollection.button.cancel'](),
onConfirm(name) {
const id = collectionService.createCollection({ name });
workbenchService.workbench.openCollection(id);
},
});
}, [collectionService, openPromptModal, t, workbenchService.workbench]);
return (
<AddItemPlaceholder
icon={<AddCollectionIcon />}
data-testid="navigation-panel-bar-add-collection-button"
label={t['com.affine.rootAppSidebar.collection.new']()}
onClick={handleCreate}
/>
);
};
const TagsAction = () => {
const t = useI18n();
const tagService = useService(TagService);
const [open, setOpen] = useState(false);
const handleCreate = useCallback(
(name: string, color: string) => {
tagService.tagList.createTag(name, color);
setOpen(false);
},
[tagService.tagList]
);
return (
<>
<AddItemPlaceholder
icon={<AddTagIcon />}
data-testid="navigation-panel-add-tag-button"
label={t[
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
]()}
onClick={() => setOpen(true)}
/>
<TagRenameDialog
open={open}
onOpenChange={setOpen}
onConfirm={handleCreate}
enableAnimation
/>
</>
);
};
const SectionAction = ({ section }: { section: string }) => {
if (section === 'favorites') return <FavoritesAction />;
if (section === 'organize') return <OrganizeAction />;
if (section === 'collections') return <CollectionsAction />;
if (section === 'tags') return <TagsAction />;
return null;
};
const NavigationAction = ({ row }: { row: MobileNavigationRow }) => {
if (row.action === 'section') {
return (
<div
className={styles.row}
data-navigation-row-id={row.id}
role="treeitem"
aria-level={2}
tabIndex={-1}
>
<SectionAction section={row.entityId ?? ''} />
</div>
);
}
if (row.action === 'folder-new-doc') return <FolderNewDocAction row={row} />;
if (row.action === 'doc-new-linked') return <DocNewLinkedAction row={row} />;
if (row.action === 'collection-new-doc') {
return <CollectionNewDocAction row={row} />;
}
if (row.action === 'tag-new-doc') return <TagNewDocAction row={row} />;
return null;
};
export const MobileNavigationVirtualScroller = () => {
const t = useI18n();
const dataProjection = useService(MobileShellDataProjection);
const navigationPanelService = useService(NavigationPanelService);
const backCoordinator = useService(MobileBackCoordinator);
const sections = useLiveData(dataProjection.navigationSections$);
const projection = useMemo(() => new MobileNavigationProjection(), []);
const restoredSnapshot = useMemo(
() => backCoordinator.snapshot('home'),
[backCoordinator]
);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => {
if (restoredSnapshot) return new Set(restoredSnapshot.expanded);
const initial = new Set<string>();
let previousSize = -1;
while (previousSize !== initial.size) {
previousSize = initial.size;
for (const row of projection.flatten(sections, initial)) {
if (
row.expandable &&
!navigationPanelService.collapsed$(rowPath(row.id)).value
) {
initial.add(row.id);
}
}
}
return initial;
});
const listRef = useRef<VirtuosoHandle>(null);
const restoredAnchor = useRef(false);
const restoredFocus = useRef(false);
const [visibleRange, setVisibleRange] = useState<ListRange>({
startIndex: 0,
endIndex: 30,
});
const rows = useMemo(
() => projection.flatten(sections, expanded),
[expanded, projection, sections]
);
const hydratedRows = useRef(new Set<string>());
useEffect(() => {
if (restoredSnapshot) return;
setExpanded(current => {
const next = new Set(current);
let changed = false;
for (const row of rows) {
if (!row.expandable || hydratedRows.current.has(row.id)) continue;
hydratedRows.current.add(row.id);
if (!navigationPanelService.collapsed$(rowPath(row.id)).value) {
changed = !next.has(row.id) || changed;
next.add(row.id);
}
}
return changed ? next : current;
});
}, [navigationPanelService, restoredSnapshot, rows]);
useEffect(() => {
const cancel = afterNextPaint(() => dataProjection.visible$.next(true));
return () => {
cancel();
dataProjection.setExpandedVisibleDocIds([]);
dataProjection.visible$.next(false);
};
}, [dataProjection]);
useEffect(() => {
return afterNextPaint(() => {
const visibleExpandedDocs = rows
.slice(visibleRange.startIndex, visibleRange.endIndex + 1)
.filter(
row =>
row.kind === 'doc' && row.expanded && row.entityId !== undefined
)
.map(row => row.entityId as string);
dataProjection.setExpandedVisibleDocIds(visibleExpandedDocs);
});
}, [dataProjection, rows, visibleRange]);
useEffect(() => {
if (restoredAnchor.current || !restoredSnapshot?.anchor) return;
const index = rows.findIndex(row => row.id === restoredSnapshot.anchor);
if (index === -1) return;
restoredAnchor.current = true;
listRef.current?.scrollToIndex({ index, align: 'start' });
}, [restoredSnapshot?.anchor, rows]);
useEffect(() => {
if (restoredFocus.current || !restoredSnapshot?.focus) return;
const element = document.querySelector<HTMLElement>(
`[data-navigation-row-id="${CSS.escape(restoredSnapshot.focus)}"]`
);
if (element) {
restoredFocus.current = true;
element.focus({ preventScroll: true });
return;
}
const index = rows.findIndex(row => row.id === restoredSnapshot.focus);
if (index !== -1)
listRef.current?.scrollToIndex({ index, align: 'center' });
}, [restoredSnapshot?.focus, rows, visibleRange]);
const saveSnapshot = useCallback(() => {
listRef.current?.getState(listState => {
const anchor = rows[visibleRange.startIndex]?.id;
backCoordinator.setSnapshot('home', {
anchor,
expanded: [...expanded],
focus: document.activeElement?.closest<HTMLElement>(
'[data-navigation-row-id]'
)?.dataset.navigationRowId,
listState,
});
});
}, [backCoordinator, expanded, rows, visibleRange.startIndex]);
useEffect(() => afterNextPaint(saveSnapshot), [saveSnapshot]);
const toggle = useCallback(
(row: MobileNavigationRow) => {
if (!row.expandable) return;
setExpanded(current => {
const next = new Set(current);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
return next;
});
navigationPanelService.setCollapsed(rowPath(row.id), !!row.expanded);
},
[navigationPanelService]
);
return (
<div className={styles.scroller}>
<Virtuoso
ref={listRef}
className={styles.virtuoso}
data={rows}
restoreStateFrom={restoredSnapshot?.listState}
overscan={{ main: 600, reverse: 300 }}
computeItemKey={(_, row) => row.id}
rangeChanged={setVisibleRange}
onFocusCapture={() => {
afterNextPaint(saveSnapshot);
}}
components={virtuosoComponents}
itemContent={(_, row) => {
if (row.kind === 'section') {
const section = row.entityId as keyof typeof sectionTitles;
return (
<div
className={styles.section}
data-first-section={section === 'favorites'}
data-navigation-row-id={row.id}
data-collapsible
data-collapsed={!row.expanded}
data-testid={`navigation-panel-${section}`}
role="treeitem"
aria-level={1}
aria-expanded={row.expanded}
tabIndex={-1}
>
<button
type="button"
className={styles.sectionButton}
onClick={() => toggle(row)}
>
{t[sectionTitles[section]]()}
<ToggleRightIcon className={styles.sectionCollapseIcon} />
</button>
</div>
);
}
if (row.kind === 'placeholder') {
return (
<div
className={styles.permission}
data-navigation-row-id={row.id}
style={{ paddingLeft: rowIndent(row.depth) }}
role="treeitem"
aria-level={row.depth + 1}
tabIndex={-1}
>
{row.blocked === 'denied'
? t['com.affine.no-permission']()
: t['Loading']()}
</div>
);
}
if (row.kind === 'action') {
return <NavigationAction row={row} />;
}
return <ProjectedEntityRow row={row} toggle={() => toggle(row)} />;
}}
/>
</div>
);
};
@@ -0,0 +1,93 @@
import { headlineRegular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { globalVars } from '../../../styles/variables.css';
export const scroller = style({
width: '100%',
height: `calc(100dvh - ${globalVars.appTabSafeArea})`,
overflowX: 'hidden',
overscrollBehavior: 'none',
touchAction: 'pan-y',
});
export const virtuoso = style({
overflowX: 'hidden',
touchAction: 'pan-y',
});
export const list = style({
boxSizing: 'border-box',
width: '100%',
padding: '0 8px 32px',
});
export const row = style({
boxSizing: 'border-box',
width: '100%',
minHeight: 44,
});
export const section = style({
display: 'flex',
alignItems: 'center',
height: 33,
padding: '0 16px 8px',
color: cssVarV2.text.primary,
selectors: {
'&:not([data-first-section="true"])': {
height: 65,
paddingTop: 32,
},
'&[data-collapsed="true"]': {
height: 25,
paddingBottom: 0,
},
'&[data-collapsed="true"]:not([data-first-section="true"])': {
height: 57,
},
},
});
export const sectionButton = style([
headlineRegular,
{
display: 'flex',
alignItems: 'center',
gap: 2,
width: '100%',
border: 0,
padding: 0,
background: 'transparent',
color: 'inherit',
textAlign: 'left',
},
]);
export const sectionCollapseIcon = style({
width: 16,
height: 16,
color: cssVarV2.icon.tertiary,
transform: 'translateY(1px) rotate(90deg)',
transition: 'transform 0.2s',
selectors: {
[`${section}[data-collapsed="true"] &`]: {
transform: 'translateY(1px) rotate(0deg)',
},
},
});
export const permission = style({
minHeight: 40,
display: 'flex',
alignItems: 'center',
color: cssVarV2.text.secondary,
fontSize: 13,
});
export const tagIcon = style({
width: 14,
height: 14,
borderRadius: '50%',
});
@@ -1,6 +1,5 @@
import { type HTMLAttributes, type ReactNode, useEffect } from 'react';
import { AppTabs } from '../app-tabs';
import * as styles from './styles.css';
interface PageProps extends HTMLAttributes<HTMLDivElement> {
@@ -25,7 +24,6 @@ export const Page = ({ children, tab = true, header, ...attrs }: PageProps) => {
<main className={styles.page} {...attrs} data-tab={tab}>
{header}
{children}
{tab ? <AppTabs fixed={false} /> : null}
</main>
);
};
@@ -1,9 +1,17 @@
import { style } from '@vanilla-extract/css';
import { globalVars } from '../../styles/variables.css';
export const page = style({
width: '100dvw',
height: '100dvh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
selectors: {
'&[data-tab="true"]': {
paddingBottom: globalVars.appTabSafeArea,
},
},
});
@@ -31,6 +31,7 @@ export const RenameDialog = ({
return (
<Modal
preserveEditingFocusOnAction
width="100%"
open={open}
onOpenChange={onOpenChange}
@@ -62,7 +62,14 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
);
useEffect(() => {
setWidth(containerRef.current?.offsetWidth ?? 0);
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(([entry]) => {
const nextWidth = entry.contentRect.width;
setWidth(current => (current === nextWidth ? current : nextWidth));
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const emitValue = useMemo(() => {
@@ -115,12 +115,17 @@ export const SwipeMenu = ({
let shouldExecute = false;
return swipeHelper.init(container, {
const dispose = swipeHelper.init(container, {
preventScroll: true,
direction: 'horizontal',
onSwipeStart() {
shouldExecute = false;
activeId$.next(id);
},
onTap() {
shouldExecute = false;
activeId$.next(null);
},
onSwipe({ deltaX: dragX, initialDirection }) {
if (initialDirection !== 'horizontal') return;
@@ -154,7 +159,27 @@ export const SwipeMenu = ({
isOpenRef.current = false;
}
},
onSwipeCancel() {
shouldExecute = false;
activeId$.next(null);
animate(
content,
menu,
{ deltaX: isOpenRef.current ? -normalWidth : 0, normalWidth },
isOpenRef.current ? -normalWidth : 0
);
},
});
return () => {
dispose();
shouldExecute = false;
if (activeId$.value === id) {
activeId$.next(null);
}
const deltaX = isOpenRef.current ? -normalWidth : 0;
tick(content, menu, { deltaX, normalWidth });
};
}, [executeThreshold, haptics, id, normalWidth, onExecute]);
return (
@@ -4,6 +4,7 @@ import { style } from '@vanilla-extract/css';
export const container = style({
position: 'relative',
overflow: 'hidden',
touchAction: 'pan-y',
});
export const content = style({
position: 'relative',
@@ -25,6 +25,7 @@ export const CurrentWorkspaceCard = forwardRef<
ref={ref}
onClick={onClick}
className={clsx(card, className)}
data-testid="workspace-selector-trigger"
{...attrs}
>
{currentWorkspace ? (
@@ -30,7 +30,12 @@ export const WorkspaceSelector = forwardRef<
// revalidate workspace list when open workspace list
useEffect(() => {
if (open) workspaceManager?.list.revalidate();
if (!open) return;
const timer = window.setTimeout(
() => workspaceManager?.list.revalidate(),
250
);
return () => window.clearTimeout(timer);
}, [workspaceManager, open]);
return (
@@ -9,7 +9,7 @@ import { style } from '@vanilla-extract/css';
export const root = style({
maxHeight:
'calc(100dvh - 100px - env(safe-area-inset-bottom) - env(safe-area-inset-top))',
'calc(100dvh - 100px - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))',
display: 'flex',
flexDirection: 'column',
});
@@ -13,6 +13,7 @@ import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { GlobalContextService } from '@affine/core/modules/global-context';
import {
type WorkspaceMetadata,
WorkspaceService,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
@@ -275,6 +276,7 @@ const AddServer = () => {
};
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const workspacesService = useService(WorkspacesService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const serversService = useService(ServersService);
@@ -314,7 +316,7 @@ export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
}
onClose?.();
},
[onClose, jumpToPage]
[currentWorkspace.id, jumpToPage, onClose]
);
return (
@@ -15,10 +15,6 @@ import { SettingDialog } from './setting';
import { SignInDialog } from './sign-in';
const GLOBAL_DIALOGS = {
// 'create-workspace': CreateWorkspaceDialog,
// 'import-workspace': ImportWorkspaceDialog,
// 'import-template': ImportTemplateDialog,
// import: ImportDialog,
'sign-in': SignInDialog,
} satisfies {
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
@@ -5,29 +5,47 @@ import {
} from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
const loadFailedToastId = 'mobile-settings-devices-load-failed';
export const DevicesGroup = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const dismissTimer = useRef<number | undefined>(undefined);
const reload = useCallback(() => {
void auth
.listDeviceSessions()
.then(setSessions)
.catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
const toastId = notify.error(
{
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
},
{ id: loadFailedToastId, duration: 5000 }
);
window.clearTimeout(dismissTimer.current);
dismissTimer.current = window.setTimeout(
() => notify.dismiss(toastId),
5000
);
});
}, [auth, t]);
useEffect(reload, [reload]);
useEffect(
() => () => {
window.clearTimeout(dismissTimer.current);
notify.dismiss(loadFailedToastId);
},
[]
);
const revoke = useCallback(
async (session: DeviceAuthSession) => {
@@ -4,7 +4,7 @@ import type {
WORKSPACE_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { AboutGroup } from './about';
@@ -20,6 +20,7 @@ import { UserUsage } from './user-usage';
const MobileSetting = () => {
const session = useService(AuthService).session;
const status = useLiveData(session.status$);
useEffect(() => session.revalidate(), [session]);
return (
@@ -27,7 +28,7 @@ const MobileSetting = () => {
<UserProfile />
<UserSubscription />
<UserUsage />
<DevicesGroup />
{status === 'authenticated' ? <DevicesGroup /> : null}
<AppearanceGroup />
<AboutGroup />
<ExperimentalFeatureSetting />
@@ -3,10 +3,10 @@ import { keyframes, style } from '@vanilla-extract/css';
const shineAnimation = keyframes({
'0%': {
backgroundPosition: 'calc(var(--shine-size) * -1) 0, 0 0',
transform: 'translateX(-100%)',
},
'100%': {
backgroundPosition: 'calc(100% + var(--shine-size)) 0, 0 0',
transform: 'translateX(100%)',
},
});
export const hotTag = style({
@@ -24,7 +24,6 @@ export const hotTag = style({
boxShadow: '0px 2px 3px rgba(0,0,0,0.1), 0px 0px 3px rgba(255,0,0, 0.5)',
vars: {
'--shine-size': '100px',
'--shine-color': 'rgba(255,255,255,0.5)',
},
@@ -37,13 +36,22 @@ export const hotTag = style({
pointerEvents: 'none',
width: '100%',
height: '100%',
// animate a shine effect
animation: `${shineAnimation} 3.6s infinite`,
transform: 'translateX(-100%)',
animation: `${shineAnimation} 900ms ease-out`,
animationFillMode: 'forwards',
backgroundImage: `linear-gradient(90deg, transparent 0%, var(--shine-color) 50%, transparent 100%)`,
backgroundRepeat: 'no-repeat, no-repeat',
backgroundSize: '100% 100%',
backgroundPosition: 'calc(var(--shine-size) * -1) 0, 0 0',
},
},
'@media': {
'(prefers-reduced-motion: reduce)': {
selectors: {
'&::after': {
animation: 'none',
},
},
},
},
});
@@ -1,8 +1,4 @@
import {
InsideModalContext,
ModalConfigContext,
Scrollable,
} from '@affine/component';
import { InsideModalContext, Scrollable } from '@affine/component';
import { PageHeader } from '@affine/core/mobile/components';
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
import { assignInlineVars } from '@vanilla-extract/dynamic';
@@ -17,6 +13,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useMobileVisualLayer } from '../../modules/back-coordinator';
import { SwipeHelper } from '../../utils';
import * as styles from './swipe-dialog.css';
@@ -142,6 +139,7 @@ const close = (
const SwipeDialogContext = createContext<{
stack: Array<React.RefObject<HTMLElement | null>>;
backLayer?: symbol;
}>({
stack: [],
});
@@ -154,17 +152,12 @@ export const SwipeDialog = ({
onOpenChange,
}: SwipeDialogProps) => {
const insideModal = useContext(InsideModalContext);
const { onOpen: globalOnOpen } = useContext(ModalConfigContext);
const swiperTriggerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const { stack } = useContext(SwipeDialogContext);
const { stack, backLayer } = useContext(SwipeDialogContext);
const prev = stack[stack.length - 1]?.current;
const swipeDialogContextValue = useMemo(
() => ({ stack: [...stack, dialogRef] }),
[stack]
);
const handleClose = useCallback(() => {
onOpenChange?.(false);
@@ -179,6 +172,15 @@ export const SwipeDialog = ({
handleClose();
}
}, [handleClose, prev]);
const layerId = useMobileVisualLayer({
enabled: open,
parent: backLayer,
onBack: animateClose,
});
const swipeDialogContextValue = useMemo(
() => ({ stack: [...stack, dialogRef], backLayer: layerId }),
[layerId, stack]
);
useEffect(() => {
if (!open) return;
@@ -205,6 +207,9 @@ export const SwipeDialog = ({
cancel(overlay, dialog, prev, deltaX);
}
},
onSwipeCancel: () => {
reset(overlay, dialog, prev);
},
});
}, [handleClose, open, prev]);
@@ -217,11 +222,6 @@ export const SwipeDialog = ({
}
}, [open, prev]);
useEffect(() => {
if (open) return globalOnOpen?.();
return;
}, [globalOnOpen, open]);
if (!open) return null;
return (
@@ -0,0 +1,160 @@
import { Framework } from '@toeverything/infra';
import { describe, expect, it, vi } from 'vitest';
import { MobileBackCoordinator } from './back-coordinator';
const createCoordinator = () => {
const framework = new Framework();
framework.service(MobileBackCoordinator);
return framework.provider().get(MobileBackCoordinator);
};
describe('MobileBackCoordinator', () => {
it('handles visual layers in registration order and disposes descendants', () => {
const coordinator = createCoordinator();
const calls: string[] = [];
const parent = coordinator.registerVisual({
handle: () => {
calls.push('parent');
},
});
coordinator.registerVisual({
parent: parent.id,
handle: () => {
calls.push('child');
},
});
expect(coordinator.request('system-back')).toBe(true);
expect(calls).toEqual(['child']);
parent.dispose();
expect(coordinator.request('system-back')).toBe(false);
});
it.each(['system-back', 'ui-back'] as const)(
'uses semantic back for %s but never pops a root',
intent => {
const coordinator = createCoordinator();
const back = vi.fn();
coordinator.setDestination({ kind: 'doc', back });
expect(coordinator.request(intent)).toBe(true);
expect(back).toHaveBeenCalledWith(intent);
coordinator.setDestination({ kind: 'all-docs', back });
expect(coordinator.request(intent)).toBe(false);
}
);
it.each([
'home',
'all-docs',
'all-collections',
'all-tags',
'journal',
] as const)('passes back through at the %s root', kind => {
const coordinator = createCoordinator();
const back = vi.fn();
coordinator.setDestination({ kind, back });
expect(coordinator.request('system-back')).toBe(false);
expect(coordinator.request('ui-back')).toBe(false);
expect(coordinator.beginInteractive()).toBe(false);
expect(back).not.toHaveBeenCalled();
});
it('keeps ui-up separate from source back', () => {
const coordinator = createCoordinator();
const back = vi.fn();
const up = vi.fn();
coordinator.setDestination({ kind: 'doc', back, up });
coordinator.request('ui-up');
expect(up).toHaveBeenCalledOnce();
expect(back).not.toHaveBeenCalled();
coordinator.setDestination({ kind: 'doc', back });
expect(coordinator.request('ui-up')).toBe(false);
});
it('commits or cancels one stable interactive target', () => {
const coordinator = createCoordinator();
const back = vi.fn();
coordinator.setDestination({ kind: 'doc', back });
expect(coordinator.beginInteractive()).toBe(true);
coordinator.cancelInteractive();
expect(back).not.toHaveBeenCalled();
expect(coordinator.beginInteractive()).toBe(true);
expect(coordinator.commitInteractive()).toBe(true);
expect(back).toHaveBeenCalledWith('interactive-gesture');
});
it('clears visual, destination and restoration state on reset', () => {
const coordinator = createCoordinator();
coordinator.setSnapshot('home', { anchor: 'doc:a', expanded: ['folder'] });
coordinator.setDestination({ kind: 'doc', back: vi.fn() });
coordinator.registerVisual({ handle: vi.fn() });
coordinator.pushSource({ workspaceId: 'a', location: '/home' });
coordinator.reset();
expect(coordinator.snapshot('home')).toBeUndefined();
expect(coordinator.canHandle$.value).toBe(false);
expect(coordinator.request('ui-back')).toBe(false);
expect(coordinator.popSource('a')).toBeUndefined();
});
it('keeps explicit source tokens workspace-scoped and LIFO', () => {
const coordinator = createCoordinator();
coordinator.pushSource({ workspaceId: 'a', location: '/home' });
coordinator.pushSource({ workspaceId: 'a', location: '/doc-a' });
expect(coordinator.popSource('b')).toBeUndefined();
expect(coordinator.popSource('a')?.location).toBe('/doc-a');
expect(coordinator.popSource('a')?.location).toBe('/home');
});
it('keeps cold deep links source-free and returns warm or inaccessible targets to their explicit source', () => {
const coordinator = createCoordinator();
const replace = vi.fn();
coordinator.setDestination({ kind: 'doc' });
expect(coordinator.request('ui-back')).toBe(false);
coordinator.pushSource({ workspaceId: 'a', location: '/home' });
coordinator.setDestination({
kind: 'doc',
back: () => {
const source = coordinator.popSource('a');
if (!source) return false;
replace(source.location);
return true;
},
});
expect(coordinator.request('ui-back')).toBe(true);
expect(replace).toHaveBeenCalledWith('/home');
});
it('closes nested menu, confirm and modal layers without reaching the destination', () => {
const coordinator = createCoordinator();
const calls: string[] = [];
const destination = vi.fn();
coordinator.setDestination({ kind: 'doc', back: destination });
const modal = coordinator.registerVisual({
handle: () => {
calls.push('modal');
},
});
const confirm = coordinator.registerVisual({
parent: modal.id,
handle: () => {
calls.push('confirm');
},
});
const menu = coordinator.registerVisual({
parent: confirm.id,
handle: () => {
calls.push('menu');
},
});
expect(coordinator.request('system-back')).toBe(true);
menu.dispose();
expect(coordinator.request('system-back')).toBe(true);
confirm.dispose();
expect(coordinator.request('system-back')).toBe(true);
expect(calls).toEqual(['menu', 'confirm', 'modal']);
expect(destination).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,269 @@
import { LiveData, Service } from '@toeverything/infra';
import type { StateSnapshot } from 'react-virtuoso';
export type MobileBackIntent =
| 'system-back'
| 'ui-back'
| 'ui-up'
| 'interactive-gesture';
export type MobileInteractiveBackPhase =
| 'begin'
| 'progress'
| 'cancel'
| 'commit';
export type MobileDestinationKind =
| 'home'
| 'all-docs'
| 'all-collections'
| 'all-tags'
| 'journal'
| 'doc'
| 'search'
| 'collection'
| 'tag'
| 'settings-child';
export type MobileRestorationSnapshot = {
anchor?: string;
expanded?: readonly string[];
focus?: string;
listState?: StateSnapshot;
};
export type MobileSourceToken = {
workspaceId: string;
location: string;
restoration?: MobileRestorationSnapshot;
};
type BackHandler = (intent: MobileBackIntent) => boolean | void;
type VisualLayer = {
id: symbol;
parent?: symbol;
enabled: () => boolean;
interactive: boolean;
handle: BackHandler;
};
type SemanticDestination = {
kind: MobileDestinationKind;
back?: BackHandler;
up?: BackHandler;
};
type InteractiveTransaction = {
target: symbol | 'destination';
};
const rootKinds = new Set<MobileDestinationKind>([
'home',
'all-docs',
'all-collections',
'all-tags',
'journal',
]);
export const isMobileRootDestination = (kind: MobileDestinationKind) =>
rootKinds.has(kind);
export class MobileBackCoordinator extends Service {
private readonly visualLayers: VisualLayer[] = [];
private destination: SemanticDestination = { kind: 'home' };
private transaction: InteractiveTransaction | undefined;
private readonly snapshots = new Map<string, MobileRestorationSnapshot>();
private readonly sources: MobileSourceToken[] = [];
readonly canHandle$ = new LiveData(false);
readonly canInteractivePop$ = new LiveData(false);
registerVisual({
id = Symbol('mobile-visual-layer'),
parent,
enabled = () => true,
interactive = true,
handle,
}: {
id?: symbol;
parent?: symbol;
enabled?: () => boolean;
interactive?: boolean;
handle: BackHandler;
}) {
const layer: VisualLayer = {
id,
parent,
enabled,
interactive,
handle,
};
this.visualLayers.push(layer);
this.publishAvailability();
return {
id: layer.id,
dispose: () => {
const removed = new Set<symbol>([layer.id]);
let changed = true;
while (changed) {
changed = false;
for (const candidate of this.visualLayers) {
if (candidate.parent && removed.has(candidate.parent)) {
changed = !removed.has(candidate.id) || changed;
removed.add(candidate.id);
}
}
}
for (let index = this.visualLayers.length - 1; index >= 0; index--) {
if (removed.has(this.visualLayers[index].id)) {
this.visualLayers.splice(index, 1);
}
}
const transactionTarget = this.transaction?.target;
if (
transactionTarget &&
transactionTarget !== 'destination' &&
removed.has(transactionTarget)
) {
this.transaction = undefined;
}
this.publishAvailability();
},
};
}
setDestination(destination: SemanticDestination) {
this.destination = destination;
this.transaction = undefined;
this.publishAvailability();
}
pushSource(source: MobileSourceToken) {
const previous = this.sources.at(-1);
if (
previous?.workspaceId === source.workspaceId &&
previous.location === source.location
) {
return;
}
this.sources.push(source);
}
popSource(workspaceId: string) {
const source = this.sources.at(-1);
if (!source || source.workspaceId !== workspaceId) return undefined;
this.sources.pop();
return source;
}
discardSources(count = this.sources.length) {
this.sources.splice(Math.max(0, this.sources.length - count), count);
}
hasSource(workspaceId: string) {
return this.sources.at(-1)?.workspaceId === workspaceId;
}
request(intent: MobileBackIntent) {
const layer = this.topLayer();
if (layer) return layer.handle(intent) !== false;
if (intent === 'ui-up') {
return this.destination.up
? this.destination.up(intent) !== false
: false;
}
if (
isMobileRootDestination(this.destination.kind) ||
!this.destination.back
) {
return false;
}
return this.destination.back(intent) !== false;
}
beginInteractive() {
if (this.transaction) return false;
const layer = this.topLayer();
if (layer) {
if (!layer.interactive) return false;
this.transaction = { target: layer.id };
return true;
}
if (
isMobileRootDestination(this.destination.kind) ||
!this.destination.back ||
!this.canInteractivePop$.value
) {
return false;
}
this.transaction = { target: 'destination' };
return true;
}
cancelInteractive() {
this.transaction = undefined;
}
commitInteractive() {
const transaction = this.transaction;
this.transaction = undefined;
if (!transaction) return false;
if (transaction.target === 'destination') {
return this.request('interactive-gesture');
}
const layer = this.visualLayers.find(
candidate => candidate.id === transaction.target && candidate.enabled()
);
return layer?.handle('interactive-gesture') !== false && !!layer;
}
handleInteractivePhase(phase: MobileInteractiveBackPhase) {
if (phase === 'begin') return this.beginInteractive();
if (phase === 'cancel') {
this.cancelInteractive();
return true;
}
if (phase === 'commit') return this.commitInteractive();
return !!this.transaction;
}
setSnapshot(key: string, snapshot: MobileRestorationSnapshot) {
this.snapshots.set(key, snapshot);
}
snapshot(key: string) {
return this.snapshots.get(key);
}
reset() {
this.visualLayers.splice(0);
this.snapshots.clear();
this.sources.splice(0);
this.destination = { kind: 'home' };
this.transaction = undefined;
this.publishAvailability();
}
private topLayer() {
for (let index = this.visualLayers.length - 1; index >= 0; index--) {
const layer = this.visualLayers[index];
if (layer.enabled()) return layer;
}
return undefined;
}
private publishAvailability() {
const layer = this.topLayer();
const destinationCanPop =
!isMobileRootDestination(this.destination.kind) &&
!!this.destination.back;
this.canHandle$.next(!!layer || destinationCanPop);
this.canInteractivePop$.next(layer ? layer.interactive : destinationCanPop);
}
override dispose() {
this.reset();
super.dispose();
}
}
@@ -0,0 +1,20 @@
import type { Framework } from '@toeverything/infra';
import { MobileBackCoordinator } from './back-coordinator';
export type {
MobileBackIntent,
MobileDestinationKind,
MobileInteractiveBackPhase,
MobileRestorationSnapshot,
MobileSourceToken,
} from './back-coordinator';
export {
isMobileRootDestination,
MobileBackCoordinator,
} from './back-coordinator';
export { useMobileVisualLayer } from './visual-layer';
export function configureMobileBackCoordinator(framework: Framework) {
framework.service(MobileBackCoordinator);
}
@@ -0,0 +1,29 @@
import { useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { MobileBackCoordinator } from './back-coordinator';
export const useMobileVisualLayer = ({
enabled,
parent,
interactive = true,
onBack,
}: {
enabled: boolean | undefined;
parent?: symbol;
interactive?: boolean;
onBack: () => void;
}) => {
const coordinator = useService(MobileBackCoordinator);
const id = useMemo(() => Symbol('mobile-visual-layer'), []);
useEffect(() => {
if (!enabled) return;
return coordinator.registerVisual({
id,
parent,
interactive,
handle: onBack,
}).dispose;
}, [coordinator, enabled, id, interactive, onBack, parent]);
return id;
};
@@ -1,13 +1,15 @@
import type { Framework } from '@toeverything/infra';
import { configureMobileBackCoordinator } from './back-coordinator';
import { configureMobileHapticsModule } from './haptics';
import { configureMobileNavigationGestureModule } from './navigation-gesture';
import { configureMobileNavigationProjection } from './navigation-projection';
import { configureMobileSearchModule } from './search';
import { configureMobileVirtualKeyboardModule } from './virtual-keyboard';
export function configureMobileModules(framework: Framework) {
configureMobileBackCoordinator(framework);
configureMobileSearchModule(framework);
configureMobileVirtualKeyboardModule(framework);
configureMobileNavigationGestureModule(framework);
configureMobileNavigationProjection(framework);
configureMobileHapticsModule(framework);
}
@@ -1,13 +0,0 @@
import type { Framework } from '@toeverything/infra';
import { NavigationGestureProvider } from './providers/navigation-gesture';
import { NavigationGestureService } from './services/navigation-gesture';
export { NavigationGestureProvider, NavigationGestureService };
export function configureMobileNavigationGestureModule(framework: Framework) {
framework.service(
NavigationGestureService,
f => new NavigationGestureService(f.getOptional(NavigationGestureProvider))
);
}
@@ -1,10 +0,0 @@
import { createIdentifier } from '@toeverything/infra';
export interface NavigationGestureProvider {
isEnabled: () => boolean;
enable: () => void;
disable: () => void;
}
export const NavigationGestureProvider =
createIdentifier<NavigationGestureProvider>('NavigationGestureProvider');
@@ -1,57 +0,0 @@
import { DebugLogger } from '@affine/debug';
import {
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
Service,
} from '@toeverything/infra';
import { catchError, distinctUntilChanged, EMPTY } from 'rxjs';
import type { NavigationGestureProvider } from '../providers/navigation-gesture';
const logger = new DebugLogger('affine:navigation-gesture');
export class NavigationGestureService extends Service {
public enabled$ = new LiveData(false);
constructor(
private readonly navigationGestureProvider?: NavigationGestureProvider
) {
super();
}
setEnabled = effect(
distinctUntilChanged<boolean>(),
exhaustMapWithTrailing((enable: boolean) => {
return fromPromise(async () => {
if (!this.navigationGestureProvider) {
return;
}
if (enable) {
await this.enable();
} else {
await this.disable();
}
return;
}).pipe(
catchError(err => {
logger.error('navigationGestureProvider error', err);
return EMPTY;
})
);
})
);
async enable() {
this.enabled$.next(true);
logger.debug(`Enable navigation gesture`);
return this.navigationGestureProvider?.enable();
}
async disable() {
this.enabled$.next(false);
logger.debug(`Disable navigation gesture`);
return this.navigationGestureProvider?.disable();
}
}
@@ -0,0 +1,34 @@
import { CollectionService } from '@affine/core/modules/collection';
import { DocsService } from '@affine/core/modules/doc';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { FavoriteService } from '@affine/core/modules/favorite';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { OrganizeService } from '@affine/core/modules/organize';
import { GuardService } from '@affine/core/modules/permissions';
import { TagService } from '@affine/core/modules/tag';
import {
WorkspaceScope,
WorkspaceService,
} from '@affine/core/modules/workspace';
import type { Framework } from '@toeverything/infra';
import { MobileShellDataProjection } from './shell-data-projection';
export * from './navigation-projection';
export * from './shell-data-projection';
export function configureMobileNavigationProjection(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(MobileShellDataProjection, [
DocsService,
DocsSearchService,
FavoriteService,
GlobalContextService,
GuardService,
WorkspaceService,
CollectionService,
OrganizeService,
TagService,
]);
}
@@ -0,0 +1,214 @@
import { describe, expect, it } from 'vitest';
import {
MobileNavigationProjection,
type MobileNavigationSection,
} from './navigation-projection';
const projection = new MobileNavigationProjection();
describe('MobileNavigationProjection', () => {
it('flattens expanded paths and preserves duplicate path identity', () => {
const sections: MobileNavigationSection[] = [
{
id: 'favorites',
children: [
{ kind: 'doc', entityId: 'a' },
{ kind: 'doc', entityId: 'a' },
{
kind: 'folder',
entityId: 'folder',
children: [{ kind: 'doc', entityId: 'b' }],
},
],
},
];
const rows = projection.flatten(
sections,
new Set(['favorites', 'favorites/folder%3Afolder'])
);
expect(rows.map(row => [row.kind, row.depth, row.entityId])).toEqual([
['section', 0, 'favorites'],
['doc', 1, 'a'],
['doc', 1, 'a'],
['folder', 1, 'folder'],
['doc', 2, 'b'],
]);
expect(rows[1].id).not.toBe(rows[2].id);
});
it('stops cycles at the current item boundary', () => {
const cyclic = { kind: 'doc', entityId: 'a' } as const;
const sections: MobileNavigationSection[] = [
{
id: 'favorites',
children: [
{
...cyclic,
children: [{ ...cyclic, children: [cyclic] }],
},
],
},
];
const rows = projection.flatten(
sections,
new Set(['favorites', 'favorites/doc%3Aa'])
);
expect(rows.map(row => row.kind)).toEqual([
'section',
'doc',
'placeholder',
]);
});
it('omits descendants of collapsed rows', () => {
const sections: MobileNavigationSection[] = [
{
id: 'folders',
children: [
{
kind: 'folder',
entityId: 'folder',
children: [{ kind: 'doc', entityId: 'doc' }],
},
],
},
];
expect(
projection
.flatten(sections, new Set(['folders']))
.map(row => row.entityId)
).toEqual(['folders', 'folder']);
});
it('preserves action semantics independently of row depth', () => {
const rows = projection.flatten(
[
{
id: 'favorites',
children: [
{
kind: 'doc',
entityId: 'doc',
expandable: true,
children: [
{
kind: 'action',
entityId: 'doc',
action: 'doc-new-linked',
},
],
},
],
},
],
new Set(['favorites', 'favorites/doc%3Adoc'])
);
expect(rows.at(-1)).toMatchObject({
kind: 'action',
depth: 2,
action: 'doc-new-linked',
});
});
it.each([
['collection', 'collection-new-doc'],
['tag', 'tag-new-doc'],
] as const)(
'keeps an empty %s expandable with its create action',
(kind, action) => {
const entityId = `empty-${kind}`;
const rowId = `items/${kind}%3A${entityId}`;
const rows = projection.flatten(
[
{
id: 'items',
children: [
{
kind,
entityId,
expandable: true,
children: [{ kind: 'action', entityId, action }],
},
],
},
],
new Set(['items', rowId])
);
expect(rows[1]).toMatchObject({
kind,
entityId,
expandable: true,
expanded: true,
});
expect(rows[2]).toMatchObject({ kind: 'action', action, depth: 2 });
}
);
it.each([
['loading', 'loading'],
['denied', 'denied'],
] as const)(
'projects %s permission as a terminal placeholder',
(permission, blocked) => {
const rows = projection.flatten(
[
{
id: 'favorites',
children: [
{
kind: 'doc',
entityId: 'private',
permission,
children: [{ kind: 'doc', entityId: 'hidden-child' }],
},
],
},
],
new Set(['favorites', 'favorites/doc%3Aprivate'])
);
expect(rows.at(-1)).toMatchObject({
kind: 'placeholder',
entityId: 'private',
blocked,
});
expect(rows.some(row => row.entityId === 'hidden-child')).toBe(false);
}
);
it.each([60, 600, 6000])(
'keeps stable mixed section paths for %i items per section',
count => {
const section = (
id: string,
kind: 'doc' | 'collection' | 'folder' | 'tag'
): MobileNavigationSection => ({
id,
children: Array.from({ length: count }, (_, index) => ({
kind,
entityId: `${kind}-${index}`,
linked: kind === 'doc' && index === 0,
})),
});
const rows = projection.flatten(
[
section('favorites', 'doc'),
section('collections', 'collection'),
section('organize', 'folder'),
section('tags', 'tag'),
],
new Set(['favorites', 'collections', 'organize', 'tags'])
);
expect(rows).toHaveLength(count * 4 + 4);
expect(rows[1]).toMatchObject({ linked: true, depth: 1 });
expect(new Set(rows.map(row => row.kind))).toEqual(
new Set(['section', 'doc', 'collection', 'folder', 'tag'])
);
expect(new Set(rows.map(row => row.id)).size).toBe(rows.length);
}
);
});
@@ -0,0 +1,143 @@
export type MobileNavigationKind =
| 'section'
| 'doc'
| 'collection'
| 'folder'
| 'tag'
| 'action'
| 'placeholder';
export type MobileNavigationNode = {
kind: Exclude<MobileNavigationKind, 'section' | 'placeholder'>;
entityId: string;
relationId?: string;
children?: readonly MobileNavigationNode[];
expandable?: boolean;
linked?: boolean;
permission?: 'allowed' | 'loading' | 'denied';
action?:
| 'section'
| 'folder-new-doc'
| 'doc-new-linked'
| 'collection-new-doc'
| 'tag-new-doc';
};
export type MobileNavigationSection = {
id: string;
children: readonly MobileNavigationNode[];
};
export type MobileNavigationRow = {
id: string;
kind: MobileNavigationKind;
depth: number;
entityId?: string;
expanded?: boolean;
expandable?: boolean;
linked?: boolean;
relationId?: string;
blocked?: 'loading' | 'denied';
action?: MobileNavigationNode['action'];
};
const pathId = (parts: readonly string[]) =>
parts.map(part => encodeURIComponent(part)).join('/');
export class MobileNavigationProjection {
flatten(
sections: readonly MobileNavigationSection[],
expanded: ReadonlySet<string>
): MobileNavigationRow[] {
const rows: MobileNavigationRow[] = [];
for (const section of sections) {
const sectionPath = [section.id];
const sectionId = pathId(sectionPath);
const sectionExpanded = expanded.has(sectionId);
rows.push({
id: sectionId,
kind: 'section',
depth: 0,
entityId: section.id,
expanded: sectionExpanded,
expandable: true,
});
if (sectionExpanded) {
this.append(
rows,
section.children,
sectionPath,
1,
expanded,
new Set()
);
}
}
return rows;
}
private append(
rows: MobileNavigationRow[],
nodes: readonly MobileNavigationNode[],
parentPath: readonly string[],
depth: number,
expanded: ReadonlySet<string>,
ancestors: ReadonlySet<string>
) {
const occurrences = new Map<string, number>();
for (const node of nodes) {
const segment = `${node.kind}:${node.entityId}`;
const occurrence = occurrences.get(segment) ?? 0;
occurrences.set(segment, occurrence + 1);
const path = [
...parentPath,
occurrence === 0 ? segment : `${segment}#${occurrence}`,
];
const id = pathId(path);
if (node.permission && node.permission !== 'allowed') {
rows.push({
id: `${id}/permission`,
kind: 'placeholder',
depth,
entityId: node.entityId,
blocked: node.permission,
});
continue;
}
if (ancestors.has(segment)) {
rows.push({
id: `${id}/cycle`,
kind: 'placeholder',
depth,
entityId: node.entityId,
});
continue;
}
const expandable = node.expandable ?? !!node.children?.length;
const isExpanded = expandable && expanded.has(id);
rows.push({
id,
kind: node.kind,
depth,
entityId: node.entityId,
expanded: expandable ? isExpanded : undefined,
expandable,
linked: node.linked,
relationId: node.relationId,
action: node.action,
});
if (isExpanded && node.children?.length) {
const nextAncestors = new Set(ancestors);
nextAncestors.add(segment);
this.append(
rows,
node.children,
path,
depth + 1,
expanded,
nextAncestors
);
}
}
}
}
@@ -0,0 +1,293 @@
/* eslint-disable rxjs/finnish */
import { Framework, LiveData } from '@toeverything/infra';
import { Observable, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { MobileShellDataProjection } from './shell-data-projection';
describe('MobileShellDataProjection', () => {
it('keeps unchanged entry identity and batches expanded refs', async () => {
const metaA$ = new LiveData({ title: 'A' });
const metaB$ = new LiveData({ title: 'B' });
const docsMap$ = new LiveData(
new Map([
['a', { meta$: metaA$ }],
['b', { meta$: metaB$ }],
])
);
const favorites$ = new LiveData<{ type: 'doc' | 'folder'; id: string }[]>(
[]
);
const activeDoc$ = new LiveData<string | null>(null);
const nonTrashIds$ = new LiveData(['a', 'b']);
const docById$ = vi.fn((id: string) =>
docsMap$.selector(docs => docs.get(id))
);
const permissionA$ = new LiveData<boolean | undefined>(true);
const can$ = vi.fn((_action: string, id: string) =>
id === 'a' ? permissionA$ : new LiveData<boolean | undefined>(true)
);
const releaseDoc = vi.fn();
const releaseIndexer = vi.fn();
const refsSubscriptions = vi.fn();
const refs$ = new Subject<
Map<string, { docId: string; title: string }[]>
>();
const missingFolder$ = new LiveData(undefined);
const collectionId = 'empty-collection';
const tagId = 'empty-tag';
const collectionDocs$ = new LiveData<string[]>([]);
const collection$ = new LiveData<
{ watch: () => Observable<string[]> } | undefined
>(undefined);
const tag = { id: tagId, pageIds$: new LiveData<string[]>([]) };
const watchRefsFrom = vi.fn(
() =>
new Observable(subscriber => {
refsSubscriptions();
return refs$.subscribe(subscriber);
})
);
const args = [
{
list: {
docsMap$,
nonTrashDocsIds$: nonTrashIds$,
doc$: docById$,
},
},
{
watchRefsFrom,
watchRefsBySourceFrom: watchRefsFrom,
indexer: {
addPriority: vi.fn(() => releaseIndexer),
waitForDocCompleted: vi.fn(() => Promise.resolve()),
},
},
{ favoriteList: { sortedList$: favorites$ } },
{
globalContext: {
docId: { $: activeDoc$ },
collectionId: { $: new LiveData(null) },
tagId: { $: new LiveData(null) },
},
},
{ can$ },
{
workspace: {
engine: { doc: { addPriority: vi.fn(() => releaseDoc) } },
},
},
{
collectionMetas$: new LiveData([
{ id: collectionId, name: 'Empty Collection' },
]),
collection$: vi.fn(() => collection$),
},
{
folderTree: {
rootFolder: { sortedChildren$: new LiveData([]) },
folderNode$: vi.fn(() => missingFolder$),
},
},
{
tagList: {
tags$: new LiveData([tag]),
tagMetas$: new LiveData([
{ id: tagId, name: 'Empty Tag', color: '#000' },
]),
tagByTagId$: vi.fn(() => new LiveData(tag)),
},
},
] as unknown as ConstructorParameters<typeof MobileShellDataProjection>;
const framework = new Framework();
framework.service(
MobileShellDataProjection,
() => new MobileShellDataProjection(...args)
);
const projection = framework.provider().get(MobileShellDataProjection);
const entryA$ = projection.entry$('a');
const entryB$ = projection.entry$('b');
const entryASubscription = entryA$.subscribe();
const entryBSubscription = entryB$.subscribe();
const navigationEntryA$ = projection.navigationEntry$('doc', 'a');
const navigationEntryASubscription = navigationEntryA$.subscribe();
const navigationEntriesSubscription =
projection.navigationEntries$.subscribe();
const firstNavigationEntryA = navigationEntryA$.value;
const sectionsChanged = vi.fn();
const sectionsSubscription =
projection.navigationSections$.subscribe(sectionsChanged);
expect(
projection.navigationSections$.value.map(section =>
section.children.at(-1)
)
).toEqual([
{ kind: 'action', entityId: 'favorites', action: 'section' },
{ kind: 'action', entityId: 'organize', action: 'section' },
{ kind: 'action', entityId: 'collections', action: 'section' },
{ kind: 'action', entityId: 'tags', action: 'section' },
]);
expect(projection.navigationSections$.value[2].children[0]).toMatchObject({
kind: 'collection',
entityId: collectionId,
expandable: true,
children: [
{
kind: 'action',
entityId: collectionId,
action: 'collection-new-doc',
},
],
});
expect(projection.navigationSections$.value[3].children[0]).toMatchObject({
kind: 'tag',
entityId: tagId,
expandable: true,
children: [{ kind: 'action', entityId: tagId, action: 'tag-new-doc' }],
});
collection$.next({ watch: () => collectionDocs$ });
collectionDocs$.next(['a']);
expect(
projection.navigationSections$.value[2].children[0].children?.[0]
).toMatchObject({ kind: 'doc', entityId: 'a' });
navigationEntryASubscription.unsubscribe();
const resumedNavigationEntryASubscription = navigationEntryA$.subscribe();
expect(navigationEntryA$.value).toBe(firstNavigationEntryA);
sectionsChanged.mockClear();
const firstA = entryA$.value;
const entryAChanged = vi.fn();
const entryAChangeSubscription = entryA$.subscribe(entryAChanged);
metaB$.next({ title: 'B2' });
expect(entryA$.value).toBe(firstA);
expect(entryB$.value?.title).toBe('B2');
expect(sectionsChanged).not.toHaveBeenCalled();
favorites$.next([{ type: 'folder', id: 'missing' }]);
expect(projection.navigationSections$.value[0].children).toEqual([
{ kind: 'action', entityId: 'favorites', action: 'section' },
]);
favorites$.next([{ type: 'doc', id: 'b' }]);
activeDoc$.next('b');
expect(entryAChanged).toHaveBeenCalledOnce();
const refsSubscription = projection.refsByDoc$.subscribe();
favorites$.next([{ type: 'doc', id: 'a' }]);
projection.setExpandedVisibleDocIds(['a']);
await Promise.resolve();
await Promise.resolve();
refs$.next(new Map([['a', [{ docId: 'b', title: 'B' }]]]));
expect(
projection.navigationSections$.value[0].children[0].children
).toHaveLength(2);
expect(
projection.navigationSections$.value[0].children[0].children?.at(-1)
).toMatchObject({ kind: 'action', action: 'doc-new-linked' });
permissionA$.next(false);
expect(
projection.navigationSections$.value[0].children[0].children ?? []
).toHaveLength(1);
releaseDoc.mockClear();
releaseIndexer.mockClear();
watchRefsFrom.mockClear();
refsSubscriptions.mockClear();
projection.setExpandedVisibleDocIds(
Array.from({ length: 600 }, (_, index) => `doc-${index}`)
);
await Promise.resolve();
await Promise.resolve();
expect(watchRefsFrom).toHaveBeenCalledTimes(1);
const [[requestedIds]] = watchRefsFrom.mock.calls as unknown as [
[string[]],
];
expect(requestedIds).toHaveLength(600);
expect(refsSubscriptions).toHaveBeenCalledTimes(1);
projection.visible$.next(false);
expect(releaseDoc).toHaveBeenCalledTimes(600);
expect(releaseIndexer).toHaveBeenCalledTimes(600);
docById$.mockClear();
can$.mockClear();
const largeIds = Array.from({ length: 600 }, (_, index) => `f-${index}`);
nonTrashIds$.next(largeIds);
favorites$.next(largeIds.map(id => ({ type: 'doc', id })));
expect(projection.navigationSections$.value[0].children).toHaveLength(601);
expect(docById$).not.toHaveBeenCalled();
expect(can$).not.toHaveBeenCalled();
refsSubscription.unsubscribe();
entryASubscription.unsubscribe();
entryAChangeSubscription.unsubscribe();
entryBSubscription.unsubscribe();
resumedNavigationEntryASubscription.unsubscribe();
navigationEntriesSubscription.unsubscribe();
sectionsSubscription.unsubscribe();
});
it('cancels late batch startup and releases priorities when hidden', async () => {
let finishIndexing: (() => void) | undefined;
const waitForDocCompleted = vi.fn(
() =>
new Promise<void>(resolve => {
finishIndexing = resolve;
})
);
const releaseDoc = vi.fn();
const releaseIndexer = vi.fn();
const watchRefsBySourceFrom = vi.fn(() => new Observable<never>());
const can$ = vi.fn(() => new LiveData(true));
const args = [
{
list: {
docsMap$: new LiveData(new Map()),
nonTrashDocsIds$: new LiveData([]),
},
},
{
watchRefsBySourceFrom,
indexer: {
addPriority: vi.fn(() => releaseIndexer),
waitForDocCompleted,
},
},
{ favoriteList: { sortedList$: new LiveData([]) } },
{ globalContext: { docId: { $: new LiveData(null) } } },
{ can$ },
{
workspace: {
engine: { doc: { addPriority: vi.fn(() => releaseDoc) } },
},
},
{ collectionMetas$: new LiveData([]) },
{
folderTree: {
rootFolder: { sortedChildren$: new LiveData([]) },
},
},
{
tagList: {
tags$: new LiveData([]),
tagMetas$: new LiveData([]),
},
},
] as unknown as ConstructorParameters<typeof MobileShellDataProjection>;
const framework = new Framework();
framework.service(
MobileShellDataProjection,
() => new MobileShellDataProjection(...args)
);
const projection = framework.provider().get(MobileShellDataProjection);
const subscription = projection.refsByDoc$.subscribe();
projection.setExpandedVisibleDocIds(['doc']);
expect(waitForDocCompleted).toHaveBeenCalledOnce();
projection.dispose();
finishIndexing?.();
await Promise.resolve();
expect(watchRefsBySourceFrom).toHaveBeenCalledOnce();
expect(releaseDoc).toHaveBeenCalledOnce();
expect(releaseIndexer).toHaveBeenCalledOnce();
subscription.unsubscribe();
});
});
@@ -0,0 +1,513 @@
import type { CollectionService } from '@affine/core/modules/collection';
import type { DocsService } from '@affine/core/modules/doc';
import type {
DocsSearchService,
IndexedDocReference,
} from '@affine/core/modules/docs-search';
import type { FavoriteService } from '@affine/core/modules/favorite';
import type { GlobalContextService } from '@affine/core/modules/global-context';
import type {
FolderNode,
OrganizeService,
} from '@affine/core/modules/organize';
import type { GuardService } from '@affine/core/modules/permissions';
import type { TagService } from '@affine/core/modules/tag';
import type { WorkspaceService } from '@affine/core/modules/workspace';
import { LiveData, MANUALLY_STOP, Service } from '@toeverything/infra';
import { combineLatest, map, Observable, of, switchMap } from 'rxjs';
import type {
MobileNavigationNode,
MobileNavigationSection,
} from './navigation-projection';
export type MobileShellDocEntry = {
id: string;
title: string;
trash: boolean;
favorite: boolean;
active: boolean;
canRead: boolean | undefined;
canUpdate: boolean | undefined;
};
export type MobileShellNavigationEntry = {
key: string;
kind: 'doc' | 'collection' | 'folder' | 'tag';
id: string;
name: string;
color?: string;
active: boolean;
canRead?: boolean;
canUpdate?: boolean;
};
const equalIds = (left: readonly string[], right: readonly string[]) =>
left.length === right.length &&
left.every((id, index) => id === right[index]);
export class MobileShellDataProjection extends Service {
private readonly entryCache = new Map<string, MobileShellDocEntry>();
private readonly docEntries = new Map<
string,
LiveData<MobileShellDocEntry | undefined>
>();
private readonly readPermissions = new Map<
string,
LiveData<boolean | undefined>
>();
private readonly updatePermissions = new Map<
string,
LiveData<boolean | undefined>
>();
private readonly expandedDocIds = new Set<string>();
private readonly collectionDocs = new Map<string, LiveData<string[]>>();
private readonly navigationEntryCache = new Map<
string,
MobileShellNavigationEntry
>();
private readonly docNavigationEntries = new Map<
string,
LiveData<MobileShellNavigationEntry | undefined>
>();
private readonly referenceCache = new Map<string, IndexedDocReference[]>();
private readonly expandedDocIds$ = new LiveData<readonly string[]>([]);
private readonly permittedExpandedDocIds$ = LiveData.computed(get =>
get(this.expandedDocIds$).filter(
id => get(this.readPermission(id)) === true
)
);
readonly visible$ = new LiveData(true);
constructor(
private readonly docs: DocsService,
private readonly docsSearch: DocsSearchService,
private readonly favorites: FavoriteService,
private readonly globalContext: GlobalContextService,
private readonly guard: GuardService,
private readonly workspace: WorkspaceService,
private readonly collections: CollectionService,
private readonly organize: OrganizeService,
private readonly tags: TagService
) {
super();
}
entry$(id: string) {
let entry$ = this.docEntries.get(id);
if (!entry$) {
const record$ = this.docs.list.doc$(id);
entry$ = LiveData.computed(get => {
const record = get(record$);
if (!record) {
this.entryCache.delete(id);
return undefined;
}
const meta = get(record.meta$);
const candidate: MobileShellDocEntry = {
id,
title: meta.title ?? '',
trash: meta.trash ?? false,
favorite: get(this.favorites.favoriteList.sortedList$).some(
item => item.type === 'doc' && item.id === id
),
active: get(this.globalContext.globalContext.docId.$) === id,
canRead: get(this.readPermission(id)),
canUpdate: get(this.updatePermission(id)),
};
const cached = this.entryCache.get(id);
if (
cached &&
cached.title === candidate.title &&
cached.trash === candidate.trash &&
cached.favorite === candidate.favorite &&
cached.active === candidate.active &&
cached.canRead === candidate.canRead &&
cached.canUpdate === candidate.canUpdate
) {
return cached;
}
this.entryCache.set(id, candidate);
return candidate;
}).distinctUntilChanged((previous, current) => previous === current);
this.docEntries.set(id, entry$);
}
return entry$;
}
readonly navigationEntries$ = LiveData.computed(get => {
const entries = new Map<string, MobileShellNavigationEntry>();
const activeCollection = get(
this.globalContext.globalContext.collectionId.$
);
const activeTag = get(this.globalContext.globalContext.tagId.$);
const add = (candidate: MobileShellNavigationEntry) => {
const cached = this.navigationEntryCache.get(candidate.key);
const entry =
cached &&
cached.name === candidate.name &&
cached.color === candidate.color &&
cached.active === candidate.active
? cached
: candidate;
this.navigationEntryCache.set(entry.key, entry);
entries.set(entry.key, entry);
};
for (const collection of get(this.collections.collectionMetas$)) {
add({
key: `collection:${collection.id}`,
kind: 'collection',
id: collection.id,
name: collection.name,
active: activeCollection === collection.id,
});
}
for (const tag of get(this.tags.tagList.tagMetas$)) {
add({
key: `tag:${tag.id}`,
kind: 'tag',
id: tag.id,
name: tag.name,
color: tag.color,
active: activeTag === tag.id,
});
}
const visitFolder = (node: FolderNode, ancestors: ReadonlySet<string>) => {
const id = node.id;
if (!id || ancestors.has(id)) return;
const nextAncestors = new Set(ancestors);
nextAncestors.add(id);
const type = get(node.type$);
if (type === 'folder') {
add({
key: `folder:${id}`,
kind: 'folder',
id,
name: get(node.name$),
active: false,
});
get(node.sortedChildren$).forEach(child =>
visitFolder(child, nextAncestors)
);
}
};
get(this.organize.folderTree.rootFolder.sortedChildren$).forEach(node =>
visitFolder(node, new Set())
);
for (const key of this.navigationEntryCache.keys()) {
if (!entries.has(key)) this.navigationEntryCache.delete(key);
}
return entries;
});
navigationEntry$(kind: MobileShellNavigationEntry['kind'], id: string) {
if (kind === 'doc') {
let entry$ = this.docNavigationEntries.get(id);
if (!entry$) {
let cached: MobileShellNavigationEntry | undefined;
const created$ = LiveData.computed<
MobileShellNavigationEntry | undefined
>(get => {
const doc = get(this.entry$(id));
if (!doc) return undefined;
const key = `doc:${id}`;
const candidate: MobileShellNavigationEntry = {
key,
kind: 'doc',
id,
name: doc.title,
active: doc.active,
canRead: doc.canRead,
canUpdate: doc.canUpdate,
};
if (
cached &&
cached.name === candidate.name &&
cached.active === candidate.active &&
cached.canRead === candidate.canRead &&
cached.canUpdate === candidate.canUpdate
) {
return cached;
}
cached = candidate;
return candidate;
}).distinctUntilChanged((previous, current) => previous === current);
this.docNavigationEntries.set(id, created$);
entry$ = created$;
}
return entry$;
}
return this.navigationEntries$.selector(entries =>
entries.get(`${kind}:${id}`)
);
}
readonly navigationSections$ = LiveData.computed(get => {
const refsByDoc = get(this.refsByDoc$);
const nonTrashDocIds = new Set(get(this.docs.list.nonTrashDocsIds$));
const docNode = (
id: string,
linked = false,
ancestors: ReadonlySet<string> = new Set()
): MobileNavigationNode | undefined => {
if (!nonTrashDocIds.has(id)) return undefined;
const nextAncestors = new Set(ancestors);
nextAncestors.add(id);
return {
kind: 'doc',
entityId: id,
linked,
expandable: true,
children: ancestors.has(id)
? []
: [
...(refsByDoc.get(id)?.flatMap(reference => {
const child = docNode(reference.docId, true, nextAncestors);
return child ? [child] : [];
}) ?? []),
{ kind: 'action', entityId: id, action: 'doc-new-linked' },
],
};
};
const collectionNode = (
id: string,
relationId?: string
): MobileNavigationNode => ({
kind: 'collection',
entityId: id,
linked: !!relationId,
relationId,
expandable: true,
children: [
...get(this.collectionDocsFor(id)).flatMap(docId => {
const child = docNode(docId, true);
return child ? [child] : [];
}),
{ kind: 'action', entityId: id, action: 'collection-new-doc' },
],
});
const tagNode = (id: string, relationId?: string): MobileNavigationNode => {
const tag = get(this.tags.tagList.tagByTagId$(id));
return {
kind: 'tag',
entityId: id,
linked: !!relationId,
relationId,
expandable: true,
children: [
...(tag
? get(tag.pageIds$).flatMap(docId => {
const child = docNode(docId, true);
return child ? [child] : [];
})
: []),
{ kind: 'action', entityId: id, action: 'tag-new-doc' },
],
};
};
const folderNode = (
node: FolderNode,
ancestors: ReadonlySet<string>
): MobileNavigationNode | undefined => {
const id = node.id ?? '';
const type = get(node.type$);
const data = get(node.data$);
if (type === 'doc' && data) {
const doc = docNode(data, true);
return doc ? { ...doc, relationId: id } : undefined;
}
if (type === 'collection' && data) return collectionNode(data, id);
if (type === 'tag' && data) return tagNode(data, id);
if (type !== 'folder') return { kind: 'folder', entityId: id };
if (ancestors.has(id)) {
return { kind: 'folder', entityId: id, children: [] };
}
const nextAncestors = new Set(ancestors);
nextAncestors.add(id);
return {
kind: 'folder',
entityId: id,
children: [
...get(node.sortedChildren$).flatMap(child => {
const projected = folderNode(child, nextAncestors);
return projected ? [projected] : [];
}),
{ kind: 'action', entityId: id, action: 'folder-new-doc' },
],
};
};
const favorites = get(this.favorites.favoriteList.sortedList$).flatMap(
item => {
let projected: MobileNavigationNode | undefined;
if (item.type === 'doc') {
projected = docNode(item.id);
} else if (item.type === 'collection') {
projected = collectionNode(item.id);
} else if (item.type === 'tag') {
projected = tagNode(item.id);
} else {
const folder = get(this.organize.folderTree.folderNode$(item.id));
projected = folder ? folderNode(folder, new Set()) : undefined;
}
return projected ? [projected] : [];
}
);
const folders = get(
this.organize.folderTree.rootFolder.sortedChildren$
).flatMap(node => {
const projected = folderNode(node, new Set());
return projected ? [projected] : [];
});
const collections = get(this.collections.collectionMetas$).map(meta =>
collectionNode(meta.id)
);
const tags = get(this.tags.tagList.tags$).map(tag => tagNode(tag.id));
return [
{
id: 'favorites',
children: [
...favorites,
{ kind: 'action', entityId: 'favorites', action: 'section' },
],
},
{
id: 'organize',
children: [
...folders,
{ kind: 'action', entityId: 'organize', action: 'section' },
],
},
{
id: 'collections',
children: [
...collections,
{ kind: 'action', entityId: 'collections', action: 'section' },
],
},
{
id: 'tags',
children: [
...tags,
{ kind: 'action', entityId: 'tags', action: 'section' },
],
},
] satisfies MobileNavigationSection[];
});
readonly refsByDoc$: LiveData<Map<string, IndexedDocReference[]>> =
LiveData.from(
combineLatest([
this.expandedDocIds$,
this.permittedExpandedDocIds$,
this.visible$,
]).pipe(
switchMap(([requestedIds, ids, visible]) => {
const permitted = new Set(ids);
requestedIds.forEach(id => {
if (!permitted.has(id)) this.referenceCache.delete(id);
});
if (!visible || ids.length === 0) {
return of(new Map(this.referenceCache));
}
return new Observable<Map<string, IndexedDocReference[]>>(
subscriber => {
const abortController = new AbortController();
const releasePriorities = ids.flatMap(id => [
this.workspace.workspace.engine.doc.addPriority(id, 10),
this.docsSearch.indexer.addPriority(id, 10),
]);
const subscription = this.docsSearch
.watchRefsBySourceFrom([...ids])
.subscribe(subscriber);
ids.forEach(id => {
this.docsSearch.indexer
.waitForDocCompleted(id, abortController.signal)
.catch(error => {
if (error !== MANUALLY_STOP) subscriber.error(error);
});
});
return () => {
abortController.abort(MANUALLY_STOP);
subscription.unsubscribe();
releasePriorities.forEach(release => release());
};
}
);
}),
map(refsByDoc => {
for (const [id, refs] of refsByDoc) {
this.referenceCache.set(id, refs);
}
return new Map(this.referenceCache);
})
),
new Map<string, IndexedDocReference[]>()
);
setExpandedVisibleDocIds(ids: Iterable<string>) {
const next = [...new Set(ids)].sort();
if (!equalIds(this.expandedDocIds$.value, next)) {
this.expandedDocIds$.next(next);
}
}
registerExpandedDoc(id: string) {
this.expandedDocIds.add(id);
this.setExpandedVisibleDocIds(this.expandedDocIds);
return () => {
this.expandedDocIds.delete(id);
this.setExpandedVisibleDocIds(this.expandedDocIds);
};
}
private readPermission(id: string) {
let permission$ = this.readPermissions.get(id);
if (!permission$) {
permission$ = this.guard.can$('Doc_Read', id);
this.readPermissions.set(id, permission$);
}
return permission$;
}
private updatePermission(id: string) {
let permission$ = this.updatePermissions.get(id);
if (!permission$) {
permission$ = this.guard.can$('Doc_Update', id);
this.updatePermissions.set(id, permission$);
}
return permission$;
}
private collectionDocsFor(id: string) {
let docs$ = this.collectionDocs.get(id);
if (!docs$) {
docs$ = LiveData.from(
this.collections
.collection$(id)
.pipe(
switchMap(collection => (collection ? collection.watch() : of([])))
),
[]
);
this.collectionDocs.set(id, docs$);
}
return docs$;
}
override dispose(): void {
this.visible$.next(false);
this.expandedDocIds.clear();
this.expandedDocIds$.next([]);
this.entryCache.clear();
this.docEntries.clear();
this.readPermissions.clear();
this.updatePermissions.clear();
this.collectionDocs.clear();
this.navigationEntryCache.clear();
this.docNavigationEntries.clear();
this.referenceCache.clear();
super.dispose();
}
}
@@ -4,9 +4,14 @@ import {
RecentDocsQuickSearchSession,
TagsQuickSearchSession,
} from '@affine/core/modules/quicksearch';
import { Service } from '@toeverything/infra';
import { LiveData, Service } from '@toeverything/infra';
export class MobileSearchService extends Service {
readonly query$ = new LiveData('');
readonly scrollAnchor$ = new LiveData(0);
readonly resultVersion$ = new LiveData(0);
readonly keyboardRestore = false;
readonly recentDocs = this.framework.createEntity(
RecentDocsQuickSearchSession
);
@@ -15,4 +20,13 @@ export class MobileSearchService extends Service {
);
readonly docs = this.framework.createEntity(DocsQuickSearchSession);
readonly tags = this.framework.createEntity(TagsQuickSearchSession);
query(value: string) {
this.query$.next(value);
this.resultVersion$.next(this.resultVersion$.value + 1);
this.recentDocs.query(value);
this.collections.query(value);
this.docs.query(value);
this.tags.query(value);
}
}
@@ -7,6 +7,10 @@ import { AppFallback } from '../components/app-fallback';
export const Component = () => {
// TODO: replace with a mobile version
return (
<IndexComponent defaultIndexRoute={'home'} fallback={<AppFallback />} />
<IndexComponent
defaultIndexRoute={'home'}
fallback={<AppFallback />}
createErrorFallback={retry => <AppFallback onRetry={retry} />}
/>
);
};
@@ -1,6 +1,5 @@
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../../components';
import { AllDocsHeader, CollectionList } from '../../../views';
export const Component = () => {
@@ -8,7 +7,6 @@ export const Component = () => {
return (
<>
<AllDocsHeader />
<AppTabs />
<CollectionList />
</>
);
@@ -39,7 +39,7 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { useMobileShellTabs } from '../../../components';
import { globalVars } from '../../../styles/variables.css';
import { JournalConflictBlock } from './journal-conflict-block';
import { JournalDatePicker } from './journal-date-picker';
@@ -285,14 +285,12 @@ const getIsLandscape = () =>
const MobileDetailPageHeader = ({
date,
fromTab,
title,
allJournalDates,
handleDateChange,
trackScrollTitle,
}: {
date?: string;
fromTab: boolean;
title?: string;
allJournalDates: Set<string | null | undefined>;
handleDateChange: (date: string) => void;
@@ -336,7 +334,7 @@ const MobileDetailPageHeader = ({
return (
<PageHeader
back={!fromTab}
back
className={styles.header}
contentClassName={styles.headerContent}
suffix={
@@ -369,14 +367,12 @@ const MobileDetailPageHeader = ({
const MobileDetailPageContent = ({
pageId,
date,
fromTab,
title,
allJournalDates,
handleDateChange,
}: {
pageId: string;
date?: string;
fromTab: boolean;
title?: string;
allJournalDates: Set<string | null | undefined>;
handleDateChange: (date: string) => void;
@@ -394,6 +390,10 @@ const MobileDetailPageContent = ({
const immersive = shouldEnableEdgelessImmersive({ mode, isLandscape });
const trackScrollTitle = shouldTrackMobileDetailPageTitleScroll(mode);
useMobileShellTabs({
background: cssVarV2('layer/background/primary'),
hidden: immersive && !chromeVisible,
});
useEffect(() => {
const mediaQuery = window.matchMedia('(orientation: landscape)');
@@ -507,7 +507,6 @@ const MobileDetailPageContent = ({
{(!immersive || chromeVisible) && (
<MobileDetailPageHeader
date={date}
fromTab={fromTab}
title={title}
allJournalDates={allJournalDates}
handleDateChange={handleDateChange}
@@ -520,10 +519,6 @@ const MobileDetailPageContent = ({
chromeVisible={chromeVisible}
immersiveTapHandlers={immersiveTapHandlers}
/>
<AppTabs
background={cssVarV2('layer/background/primary')}
hidden={immersive && !chromeVisible}
/>
</>
);
};
@@ -544,22 +539,22 @@ const MobileDetailPage = ({
const allJournalDates = useLiveData(journalService.allJournalDates$);
const location = useLiveData(workbench.location$);
const fromTab = location.search.includes('fromTab');
const handleDateChange = useCallback(
(date: string) => {
const docs = journalService.journalsByDate$(date).value;
if (docs.length > 0) {
workbench.openDoc(
{ docId: docs[0].id, fromTab: fromTab ? 'true' : undefined },
{ replaceHistory: true }
);
workbench.openDoc(docs[0].id, {
at: 'active',
replaceHistory: true,
});
} else {
workbench.open(`/journals?date=${date}`);
workbench.open(`/journals?date=${date}`, {
at: 'active',
replaceHistory: true,
});
}
},
[fromTab, journalService, workbench]
[journalService, workbench]
);
return (
@@ -574,7 +569,6 @@ const MobileDetailPage = ({
key={pageId}
pageId={pageId}
date={date}
fromTab={fromTab}
title={title}
allJournalDates={allJournalDates}
handleDateChange={handleDateChange}
@@ -28,6 +28,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import { truncate } from 'lodash-es';
import { useCallback, useEffect, useState } from 'react';
import { MobileBackCoordinator } from '../../../modules/back-coordinator';
import { JournalConflictsMenuItem } from './menu/journal-conflicts';
import { JournalTodayActivityMenuItem } from './menu/journal-today-activity';
import { EditorModeSwitch } from './menu/mode-switch';
@@ -54,6 +55,7 @@ export const PageHeaderMenuButton = () => {
const { favorite, toggleFavorite } = useFavorite(docId);
const { openConfirmModal } = useConfirmModal();
const backCoordinator = useService(MobileBackCoordinator);
const handleSwitchMode = useCallback(() => {
const mode = primaryMode === 'page' ? 'edgeless' : 'page';
@@ -81,7 +83,6 @@ export const PageHeaderMenuButton = () => {
}
setOpen(open);
}, []);
useEffect(() => {
// when the location is changed, close the menu
handleMenuOpenChange(false);
@@ -112,11 +113,12 @@ export const PageHeaderMenuButton = () => {
control: 'button',
});
toast(t['com.affine.toastMessage.movedTrash']());
// navigate back
history.back();
if (!backCoordinator.request('ui-back')) {
backCoordinator.request('ui-up');
}
},
});
}, [doc, openConfirmModal, t]);
}, [backCoordinator, doc, openConfirmModal, t]);
const EditMenu = (
<>
@@ -1,37 +1,9 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../components';
import {
NavigationPanelCollections,
NavigationPanelFavorites,
NavigationPanelOrganize,
NavigationPanelTags,
} from '../../components/navigation';
import { HomeHeader, RecentDocs } from '../../views';
import { MobileNavigationVirtualScroller } from '../../components/navigation';
export const Component = () => {
useThemeColorV2('layer/background/mobile/primary');
return (
<>
<HomeHeader />
<RecentDocs />
<SafeArea bottom>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 32,
padding: '0 8px 32px 8px',
}}
>
<NavigationPanelFavorites />
<NavigationPanelOrganize />
<NavigationPanelCollections />
<NavigationPanelTags />
</div>
</SafeArea>
<AppTabs />
</>
);
return <MobileNavigationVirtualScroller />;
};
@@ -10,11 +10,12 @@ import { cssVarV2 } from '@toeverything/theme/v2';
import dayjs from 'dayjs';
import { useCallback, useLayoutEffect, useState } from 'react';
import { AppTabs, PageHeader } from '../../components';
import { PageHeader, useMobileShellTabs } from '../../components';
import { JournalDatePicker } from './detail/journal-date-picker';
import * as styles from './journals.css';
export const JournalsPageWithConfirmation = () => {
useMobileShellTabs({ background: cssVarV2('layer/background/primary') });
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
@@ -25,7 +26,10 @@ export const JournalsPageWithConfirmation = () => {
const handleDateChange = useCallback(
(date: string) => {
workbench.open(`/journals?date=${date}`, { at: 'active' });
workbench.open(`/journals?date=${date}`, {
at: 'active',
replaceHistory: true,
});
},
[workbench]
);
@@ -49,27 +53,24 @@ export const JournalsPageWithConfirmation = () => {
if (!ready) return null;
return (
<>
<div className={styles.container}>
<PageHeader
className={styles.header}
bottom={
<JournalDatePicker
date={dateString}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
}
contentClassName={styles.headerTitle}
bottomSpacer={94}
>
{i18nTime(dayjs(dateString), { absolute: { accuracy: 'month' } })}
</PageHeader>
<JournalPlaceholder dateString={dateString} />
</div>
<AppTabs background={cssVarV2('layer/background/primary')} />
</>
<div className={styles.container}>
<PageHeader
className={styles.header}
bottom={
<JournalDatePicker
date={dateString}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
}
contentClassName={styles.headerTitle}
bottomSpacer={94}
>
{i18nTime(dayjs(dateString), { absolute: { accuracy: 'month' } })}
</PageHeader>
<JournalPlaceholder dateString={dateString} />
</div>
);
};
@@ -18,6 +18,7 @@ import {
FrameworkScope,
LiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import {
@@ -30,7 +31,9 @@ import {
import { map } from 'rxjs';
import { AppFallback } from '../../components/app-fallback';
import { MobileShellHost } from '../../components/mobile-shell-host';
import { WorkspaceDialogs } from '../../dialogs';
import { MobileBackCoordinator } from '../../modules/back-coordinator';
// TODO(@forehalo): reuse the global context with [core/electron]
declare global {
@@ -134,19 +137,31 @@ export const WorkspaceLayout = ({
return (
<FrameworkScope scope={workspaceServer?.scope}>
<FrameworkScope scope={workspace.scope}>
<WorkspaceBackReset workspaceId={workspace.id} />
<AffineErrorBoundary height="100dvh">
<SWRConfigProvider>
<WorkspaceDialogs />
<MobileShellHost>
<WorkspaceDialogs />
{/* ---- some side-effect components ---- */}
<PeekViewManagerModal />
<AiLoginRequiredModal />
<uniReactRoot.Root />
<WorkspaceSideEffects />
{children}
{/* ---- some side-effect components ---- */}
<PeekViewManagerModal />
<AiLoginRequiredModal />
<uniReactRoot.Root />
<WorkspaceSideEffects />
{children}
</MobileShellHost>
</SWRConfigProvider>
</AffineErrorBoundary>
</FrameworkScope>
</FrameworkScope>
);
};
const WorkspaceBackReset = ({ workspaceId }: { workspaceId: string }) => {
const coordinator = useService(MobileBackCoordinator);
useEffect(() => {
coordinator.reset();
return () => coordinator.reset();
}, [coordinator, workspaceId]);
return null;
};
@@ -21,20 +21,20 @@ import {
useServices,
} from '@toeverything/infra';
import { bodyEmphasized } from '@toeverything/theme/typography';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import {
NavigationBackButton,
SearchInput,
SearchResLabel,
useMobileShellTabs,
} from '../../components';
import { searchVTScope } from '../../components/search-input/style.css';
import { MobileBackCoordinator } from '../../modules/back-coordinator';
import { MobileSearchService } from '../../modules/search';
import { SearchResults } from '../../views/search/search-results';
import * as styles from '../../views/search/style.css';
const searchInput$ = new LiveData('');
const RecentList = () => {
const { mobileSearchService, collectionService, tagService } = useServices({
MobileSearchService,
@@ -134,31 +134,36 @@ const WithQueryList = () => {
export const Component = () => {
const t = useI18n();
useThemeColorV2('layer/background/mobile/primary');
const searchInput = useLiveData(searchInput$);
useMobileShellTabs({ hidden: true });
const searchService = useService(MobileSearchService);
const backCoordinator = useService(MobileBackCoordinator);
const searchInput = useLiveData(searchService.query$);
const onSearch = useCallback(
(v: string) => {
searchInput$.next(v);
searchService.recentDocs.query(v);
searchService.collections.query(v);
searchService.docs.query(v);
searchService.tags.query(v);
searchService.query(v);
},
[
searchService.collections,
searchService.docs,
searchService.recentDocs,
searchService.tags,
]
[searchService]
);
useEffect(() => {
const frame = requestAnimationFrame(() => {
window.scrollTo({ top: searchService.scrollAnchor$.value });
});
return () => {
cancelAnimationFrame(frame);
searchService.scrollAnchor$.next(window.scrollY);
};
}, [searchService]);
const transitionBack = useCallback(() => {
startScopedViewTransition(searchVTScope, async () => {
history.back();
if (!backCoordinator.request('ui-back')) {
backCoordinator.request('ui-up');
}
await sleep(10);
});
}, []);
}, [backCoordinator]);
return (
<>
@@ -1,6 +1,5 @@
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../../components';
import { AllDocsHeader, TagList } from '../../../views';
export const Component = () => {
@@ -8,7 +7,6 @@ export const Component = () => {
return (
<>
<AllDocsHeader />
<AppTabs />
<TagList />
</>
);
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { render, waitFor } from '@testing-library/react';
import { type PropsWithChildren, useEffect } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { describe, expect, it, vi } from 'vitest';
const Activity = Reflect.get(React, 'Activity') as React.ComponentType<
PropsWithChildren<{ mode: 'visible' | 'hidden' }>
>;
describe('React Activity mobile contract', () => {
it('disconnects effects while hidden and reconnects them when visible', async () => {
const connect = vi.fn();
const disconnect = vi.fn();
const Probe = () => {
useEffect(() => {
connect();
return disconnect;
}, []);
return <button type="button">home focus</button>;
};
const view = render(
<Activity mode="visible">
<Probe />
</Activity>
);
await waitFor(() => expect(connect).toHaveBeenCalledOnce());
view.rerender(
<Activity mode="hidden">
<Probe />
</Activity>
);
await waitFor(() => expect(disconnect).toHaveBeenCalledOnce());
view.rerender(
<Activity mode="visible">
<Probe />
</Activity>
);
await waitFor(() => expect(connect).toHaveBeenCalledTimes(2));
});
it('hides portaled content and exposes the raw focus contract', async () => {
const portalHost = document.createElement('div');
document.body.append(portalHost);
const Probe = () => (
<>
<button type="button">home focus</button>
{createPortal(<div>home portal</div>, portalHost)}
</>
);
const view = render(
<Activity mode="visible">
<Probe />
</Activity>
);
const button = view.container.querySelector('button');
expect(button).not.toBeNull();
if (!button) return;
button.focus();
expect(document.activeElement).toBe(button);
view.rerender(
<Activity mode="hidden">
<Probe />
</Activity>
);
expect(document.activeElement).toBe(button);
expect(
getComputedStyle(portalHost.firstElementChild as Element).display
).toBe('none');
portalHost.remove();
});
});
@@ -3,17 +3,65 @@ import {
WorkbenchService,
} from '@affine/core/modules/workbench';
import { ViewRoot } from '@affine/core/modules/workbench/view/view-root';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import * as React from 'react';
import { type RouteObject, useLocation } from 'react-router-dom';
import {
isMobileRootDestination,
MobileBackCoordinator,
type MobileDestinationKind,
} from '../../modules/back-coordinator';
import { Component as Home } from './home';
const Activity = Reflect.get(React, 'Activity') as React.ComponentType<
PropsWithChildren<{ mode: 'visible' | 'hidden' }>
>;
declare global {
interface WindowEventMap {
'affine:memory-pressure': Event;
}
}
const destinationKind = (pathname: string): MobileDestinationKind => {
if (pathname === '/home') return 'home';
if (pathname === '/all') return 'all-docs';
if (pathname === '/collection') return 'all-collections';
if (pathname === '/tag') return 'all-tags';
if (pathname === '/journals') return 'journal';
if (pathname === '/search') return 'search';
if (pathname.startsWith('/collection/')) return 'collection';
if (pathname.startsWith('/tag/')) return 'tag';
return 'doc';
};
export const MobileWorkbenchRoot = ({ routes }: { routes: RouteObject[] }) => {
const workbench = useService(WorkbenchService).workbench;
// for debugging
(window as any).workbench = workbench;
const workspaceId = useService(WorkspaceService).workspace.id;
const views = useLiveData(workbench.views$);
const view = views[0];
const viewLocation = useLiveData(view.location$);
const backCoordinator = useService(MobileBackCoordinator);
const isHome = viewLocation.pathname === '/home';
const [homeCache, setHomeCache] = useState({ workspaceId, cached: isHome });
const homeCached =
homeCache.workspaceId === workspaceId ? homeCache.cached : isHome;
const homeRootRef = useRef<HTMLDivElement>(null);
const previousLocation = useRef({
index: view.history.index,
location: `${viewLocation.pathname}${viewLocation.search}${viewLocation.hash}`,
kind: destinationKind(viewLocation.pathname),
});
const location = useLocation();
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
@@ -24,5 +72,91 @@ export const MobileWorkbenchRoot = ({ routes }: { routes: RouteObject[] }) => {
workbench.updateBasename(basename);
}, [basename, workbench]);
return <ViewRoot routes={routes} view={views[0]} />;
useEffect(() => {
const kind = destinationKind(viewLocation.pathname);
const currentLocation = `${viewLocation.pathname}${viewLocation.search}${viewLocation.hash}`;
const previous = previousLocation.current;
const index = view.history.index;
if (index > previous.index && !isMobileRootDestination(kind)) {
backCoordinator.pushSource({
workspaceId,
location: previous.location,
restoration:
previous.kind === 'home'
? backCoordinator.snapshot('home')
: undefined,
});
} else if (index < previous.index) {
backCoordinator.discardSources(previous.index - index);
}
if (
isMobileRootDestination(kind) &&
previous.location !== currentLocation
) {
backCoordinator.discardSources();
}
previousLocation.current = {
index,
location: currentLocation,
kind,
};
const canGoBack = backCoordinator.hasSource(workspaceId);
backCoordinator.setDestination({
kind,
back: canGoBack
? () => {
const source = backCoordinator.popSource(workspaceId);
if (!source) return false;
view.replace(source.location);
return true;
}
: undefined,
up: () => view.replace('/home'),
});
}, [backCoordinator, view, viewLocation, workspaceId]);
useEffect(() => {
setHomeCache(current => {
if (current.workspaceId !== workspaceId) {
return { workspaceId, cached: isHome };
}
return isHome && !current.cached
? { workspaceId, cached: true }
: current;
});
}, [isHome, workspaceId]);
useLayoutEffect(() => {
if (
!isHome &&
document.activeElement instanceof HTMLElement &&
homeRootRef.current?.contains(document.activeElement)
) {
document.activeElement.blur();
}
}, [isHome]);
useEffect(() => {
const evict = () => {
if (!isHome) setHomeCache({ workspaceId, cached: false });
};
window.addEventListener('affine:memory-pressure', evict);
return () => window.removeEventListener('affine:memory-pressure', evict);
}, [isHome, workspaceId]);
const root = (
<>
{(homeCached || isHome) && (
<Activity key={workspaceId} mode={isHome ? 'visible' : 'hidden'}>
<FrameworkScope scope={view.scope}>
<div ref={homeRootRef}>
<Home />
</div>
</FrameworkScope>
</Activity>
)}
{!isHome && <ViewRoot routes={routes} view={view} />}
</>
);
return root;
};
@@ -6,7 +6,7 @@ import { globalVars } from './variables.css';
globalStyle(':root', {
vars: {
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))`,
'--affine-edgeless-zoom-toolbar-bottom': `calc(10px + ${globalVars.appTabSafeArea})`,
},
userSelect: 'none',
@@ -48,6 +48,7 @@ export interface SwipeHelperOptions {
onSwipeStart?: () => void;
onSwipe?: (info: SwipeInfo) => void;
onSwipeEnd?: (info: SwipeInfo) => void;
onSwipeCancel?: () => void;
}
const defaultOptions = Object.freeze({
scope: 'global',
@@ -63,6 +64,7 @@ export class SwipeHelper {
private _lastInfo: SwipeInfo | null = null;
private _startTime: number = 0;
private _lastTime: number = 0;
private _active = false;
get scopeElement() {
return this._options.scope === 'inside'
@@ -79,7 +81,7 @@ export class SwipeHelper {
*/
public init(trigger: HTMLElement, options?: SwipeHelperOptions) {
this.destroy();
this._options = { ...this._options, ...options };
this._options = { ...defaultOptions, ...options };
this._trigger = trigger;
const handler = this._handleTouchStart.bind(this);
trigger.addEventListener('touchstart', handler, {
@@ -97,10 +99,11 @@ export class SwipeHelper {
*/
public destroy() {
this._dragStartCleanup();
this._clearDrag();
this._cancelActiveDrag(false);
}
private _handleTouchStart(e: TouchEvent) {
this._cancelActiveDrag();
const touch = e.touches[0];
this._startPos = {
x: touch.clientX,
@@ -108,20 +111,27 @@ export class SwipeHelper {
};
this._lastTime = Date.now();
this._startTime = this._lastTime;
this._isFirst = true;
this._active = true;
this._options.onSwipeStart?.();
const moveHandler = this._handleTouchMove.bind(this);
this.scopeElement.addEventListener('touchmove', moveHandler, {
passive: !this._options.preventScroll,
});
const endHandler = this._handleTouchEnd.bind(this);
const cancelHandler = this._handleTouchCancel.bind(this);
this.scopeElement.addEventListener('touchend', endHandler, {
passive: !this._options.preventScroll,
});
this.scopeElement.addEventListener('touchcancel', cancelHandler, {
passive: !this._options.preventScroll,
});
this._dragMoveCleanup = () => {
this.scopeElement.removeEventListener('touchmove', moveHandler);
};
this._dragEndCleanup = () => {
this.scopeElement.removeEventListener('touchend', endHandler);
this.scopeElement.removeEventListener('touchcancel', cancelHandler);
};
}
@@ -129,12 +139,9 @@ export class SwipeHelper {
const info = this._getInfo(e);
if (
this._options.preventScroll &&
// direction is not detected
(!this._direction ||
// direction is not specified
!this._options.direction ||
// direction is same as specified
this._direction === this._options.direction)
// iOS cannot resume native scrolling once an early touchmove is
// cancelled, so constrained swipes wait for a matching direction.
(!this._options.direction || this._direction === this._options.direction)
) {
e.preventDefault();
}
@@ -144,16 +151,30 @@ export class SwipeHelper {
}
private _handleTouchEnd() {
if (
!this._lastInfo ||
(Math.abs(this._lastInfo.deltaY) < 1 &&
Math.abs(this._lastInfo.deltaX) < 1)
) {
this._options.onTap?.();
} else {
this._options.onSwipeEnd?.(this._lastInfo);
}
if (!this._active) return;
const info = this._lastInfo;
const isTap =
!info || (Math.abs(info.deltaY) < 1 && Math.abs(info.deltaX) < 1);
this._active = false;
this._clearDrag();
if (isTap) {
this._options.onTap?.();
} else if (info) {
this._options.onSwipeEnd?.(info);
}
}
private _handleTouchCancel() {
this._cancelActiveDrag();
}
private _cancelActiveDrag(notify = true) {
const wasActive = this._active;
this._active = false;
this._clearDrag();
if (wasActive && notify) {
this._options.onSwipeCancel?.();
}
}
private _getInfo(e: TouchEvent): SwipeInfo {
@@ -13,11 +13,10 @@ import { NotificationIcon, SettingsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { SearchInput, WorkspaceSelector } from '../../components';
import { searchVTScope } from '../../components/search-input/style.css';
import { useGlobalEvent } from '../../hooks/use-global-events';
import * as styles from './styles.css';
/**
@@ -30,11 +29,11 @@ export const HomeHeader = () => {
const workspaceDialogService = useService(WorkspaceDialogService);
const workspaceCardRef = useRef<HTMLDivElement>(null);
const floatWorkspaceCardRef = useRef<HTMLDivElement>(null);
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const notificationCountService = useService(NotificationCountService);
const notificationCount = useLiveData(notificationCountService.count$);
const loggedIn = useLiveData(notificationCountService.loggedIn$);
const navSearch = useCallback(() => {
startScopedViewTransition(searchVTScope, () => {
@@ -44,16 +43,16 @@ export const HomeHeader = () => {
const [dense, setDense] = useState(false);
useGlobalEvent(
'scroll',
useCallback(() => {
if (!workspaceCardRef.current || !floatWorkspaceCardRef.current) return;
const inFlowTop = workspaceCardRef.current.getBoundingClientRect().top;
const floatTop =
floatWorkspaceCardRef.current.getBoundingClientRect().top;
setDense(inFlowTop <= floatTop);
}, [])
);
useEffect(() => {
const workspaceCard = workspaceCardRef.current;
if (!workspaceCard) return;
const observer = new IntersectionObserver(
([entry]) => setDense(!entry.isIntersecting),
{ threshold: 0.01 }
);
observer.observe(workspaceCard);
return () => observer.disconnect();
}, []);
const openSetting = useCallback(() => {
workspaceDialogService.open('setting', {
@@ -72,38 +71,44 @@ export const HomeHeader = () => {
</SafeArea>
{/* float */}
<SafeArea top className={clsx(styles.root, styles.float, { dense })}>
<WorkspaceSelector
className={styles.floatWsSelector}
ref={floatWorkspaceCardRef}
/>
<Menu items={<NotificationList />}>
<div
style={{
position: 'relative',
lineHeight: 0,
color: cssVarV2.icon.primary,
}}
>
<NotificationIcon width={28} height={28} />
{notificationCount > 0 && (
<div
className={styles.notificationBadge}
style={{
fontSize: notificationCount > 99 ? '8px' : '12px',
}}
>
{notificationCount > 99 ? '99+' : notificationCount}
</div>
)}
</div>
</Menu>
<IconButton
style={{ transition: 'none' }}
onClick={openSetting}
size={28}
icon={<SettingsIcon />}
data-testid="settings-button"
/>
<div className={styles.floatContent}>
<WorkspaceSelector className={styles.floatWsSelector} />
<Menu items={<NotificationList />}>
<button
type="button"
aria-label={t['com.affine.rootAppSidebar.notifications']()}
data-testid="notification-button"
className={styles.headerAction}
style={{
position: 'relative',
lineHeight: 0,
color: cssVarV2.icon.primary,
border: 0,
background: 'none',
}}
>
<NotificationIcon width={28} height={28} />
{loggedIn && notificationCount > 0 && (
<div
className={styles.notificationBadge}
style={{
fontSize: notificationCount > 99 ? '8px' : '12px',
}}
>
{notificationCount > 99 ? '99+' : notificationCount}
</div>
)}
</button>
</Menu>
<IconButton
className={styles.headerAction}
style={{ transition: 'none' }}
onClick={openSetting}
size={28}
icon={<SettingsIcon />}
data-testid="settings-button"
/>
</div>
</SafeArea>
</>
);
@@ -14,7 +14,7 @@ export const root = style({
width: '100dvw',
});
export const headerSettingRow = style({
height: 44,
height: 56,
});
export const wsSelectorAndSearch = style({
display: 'flex',
@@ -29,11 +29,6 @@ export const float = style({
width: '100%',
zIndex: 2,
display: 'flex',
alignItems: 'center',
padding: '4px 10px 4px 16px',
gap: 10,
// visibility control
background: 'transparent',
selectors: {
@@ -42,6 +37,33 @@ export const float = style({
},
},
});
export const floatContent = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
height: 56,
padding: '0 10px 0 16px',
gap: 10,
selectors: {
[`${float}.dense &::after`]: {
content: '',
position: 'absolute',
right: 0,
bottom: 0,
left: 0,
height: '0.5px',
background: cssVarV2('layer/insideBorder/border'),
},
},
});
export const headerAction = style({
display: 'inline-flex',
width: 44,
height: 44,
flex: '0 0 44px',
alignItems: 'center',
justifyContent: 'center',
});
export const floatWsSelector = style({
width: 0,
flex: 1,
@@ -4,7 +4,7 @@ import { useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { DocCard } from '../../components/doc-card';
import { CollapsibleSection } from '../../components/navigation';
import { CollapsibleSection } from '../../components/navigation/layouts/collapsible-section';
import * as styles from './styles.css';
export const RecentDocs = ({ max = 5 }: { max?: number }) => {
@@ -1,4 +1,7 @@
export { DocsSearchService } from './services/docs-search';
export {
DocsSearchService,
type IndexedDocReference,
} from './services/docs-search';
import { type Framework } from '@toeverything/infra';
@@ -0,0 +1,131 @@
/** @vitest-environment happy-dom */
/* eslint-disable rxjs/finnish */
import { Framework } from '@toeverything/infra';
import { firstValueFrom, of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocsSearchService } from './docs-search';
describe('DocsSearchService refs', () => {
it('skips malformed reference items without poisoning the result', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const indexer = {
state$: of({ indexing: 0, errorMessage: null }),
search$: vi.fn().mockReturnValue(
of({
nodes: [
{
fields: {
docId: 'source',
ref: [
JSON.stringify({ docId: 'target' }),
'{invalid-json',
JSON.stringify({ docId: 42 }),
7,
],
},
},
],
})
),
};
const workspaceService = { workspace: { engine: { indexer } } };
const docsService = {
list: {
doc$: (id: string) => ({
value:
id === 'target'
? { id, title$: { value: 'Target document' } }
: null,
}),
},
};
const framework = new Framework();
framework.service(
DocsSearchService,
() =>
new DocsSearchService(
workspaceService as unknown as ConstructorParameters<
typeof DocsSearchService
>[0],
docsService as unknown as ConstructorParameters<
typeof DocsSearchService
>[1]
)
);
const service = framework.provider().get(DocsSearchService);
await expect(
firstValueFrom(service.watchRefsFrom('source'))
).resolves.toEqual([
{ docId: 'target', title: 'Target document', params: undefined },
]);
expect(warn).toHaveBeenCalledWith(
'[docs-search] skipped malformed references',
{ count: 3 }
);
warn.mockRestore();
});
it('groups one batch of references by source document', async () => {
const indexer = {
state$: of({ indexing: 0, errorMessage: null }),
search$: vi.fn().mockReturnValue(
of({
nodes: [
{
fields: {
docId: 'a',
ref: JSON.stringify({ docId: 'target-a' }),
},
},
{
fields: {
docId: ['b'],
ref: [
JSON.stringify({ docId: 'target-b' }),
JSON.stringify({ docId: 'b' }),
],
},
},
],
})
),
};
const docs = new Map([
['target-a', 'Target A'],
['target-b', 'Target B'],
]);
const framework = new Framework();
framework.service(
DocsSearchService,
() =>
new DocsSearchService(
{ workspace: { engine: { indexer } } } as never,
{
list: {
doc$: (id: string) => ({
value: docs.has(id)
? { id, title$: { value: docs.get(id) } }
: null,
}),
},
} as never
)
);
const service = framework.provider().get(DocsSearchService);
const grouped = await firstValueFrom(
service.watchRefsBySourceFrom(['a', 'b'])
);
expect([...grouped]).toEqual([
['a', [{ docId: 'target-a', title: 'Target A', params: undefined }]],
['b', [{ docId: 'target-b', title: 'Target B', params: undefined }]],
]);
expect(indexer.search$).toHaveBeenCalledTimes(1);
expect(indexer.search$.mock.calls[0][2]).toMatchObject({
pagination: { limit: Infinity },
});
});
});
@@ -1,6 +1,9 @@
import { toDocSearchParams } from '@affine/core/modules/navigation';
import type { IndexerPreferOptions, IndexerSyncState } from '@affine/nbstore';
import type { ReferenceParams } from '@blocksuite/affine/model';
import {
type ReferenceParams,
ReferenceParamsSchema,
} from '@blocksuite/affine/model';
import { fromPromise, LiveData, Service } from '@toeverything/infra';
import { isEmpty, omit } from 'lodash-es';
import {
@@ -16,6 +19,61 @@ import { normalizeSearchText } from '../../../utils/normalize-search-text';
import type { DocsService } from '../../doc/services/docs';
import type { WorkspaceService } from '../../workspace';
const IndexedReferenceSchema = ReferenceParamsSchema.extend({
docId: z.string().min(1),
});
function parseIndexedReferences(value: unknown) {
const payloads =
typeof value === 'string'
? [value]
: Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
const refs: ({ docId: string } & ReferenceParams)[] = [];
let malformed = Array.isArray(value)
? value.length - payloads.length
: typeof value === 'string'
? 0
: 1;
for (const payload of payloads) {
try {
const result = IndexedReferenceSchema.safeParse(JSON.parse(payload));
if (result.success) {
refs.push(result.data);
} else {
malformed++;
}
} catch {
malformed++;
}
}
return { refs, malformed };
}
export type IndexedDocReference = {
title: string;
docId: string;
params?: ReturnType<typeof toDocSearchParams>;
};
const stringField = (value: unknown) =>
typeof value === 'string'
? value
: Array.isArray(value) && typeof value[0] === 'string'
? value[0]
: null;
const equalReferenceSets = (
previous: readonly IndexedDocReference[],
current: readonly IndexedDocReference[]
) => {
if (previous.length !== current.length) return false;
const currentIds = new Set(current.map(reference => reference.docId));
return previous.every(reference => currentIds.has(reference.docId));
};
export class DocsSearchService extends Service {
constructor(
private readonly workspaceService: WorkspaceService,
@@ -176,6 +234,29 @@ export class DocsSearchService extends Service {
return of([]);
}
return this.watchRefsBySourceFrom(docIds).pipe(
map((refsBySource): IndexedDocReference[] => {
const refs = Array.from(refsBySource.values()).flat();
return Array.from(
new Map(
refs
.filter(ref => !docIds.includes(ref.docId))
.map(ref => [ref.docId, ref])
).values()
);
}),
distinctUntilChanged((previous, current) =>
equalReferenceSets(previous, current)
)
);
}
watchRefsBySourceFrom(ids: string | string[]) {
const docIds = Array.isArray(ids) ? ids : [ids];
if (docIds.length === 0) {
return of(new Map<string, IndexedDocReference[]>());
}
return this.indexer
.search$(
'block',
@@ -199,62 +280,71 @@ export class DocsSearchService extends Service {
],
},
{
fields: ['refDocId', 'ref'],
fields: ['docId', 'refDocId', 'ref'],
pagination: {
limit: 100,
limit: Infinity,
},
}
)
.pipe(
switchMap(({ nodes }) => {
return fromPromise(async () => {
const refs: ({ docId: string } & ReferenceParams)[] = Array.from(
new Map(
nodes
.flatMap(node => {
const { ref } = node.fields;
return typeof ref === 'string'
? [JSON.parse(ref)]
: ref.map(item => JSON.parse(item));
})
.filter(ref => !docIds.includes(ref.docId))
.map(ref => [ref.docId, ref])
).values()
let malformed = 0;
const refsBySource = new Map<
string,
Map<string, { docId: string } & ReferenceParams>
>();
for (const node of nodes) {
const sourceId = stringField(node.fields.docId);
const parsed = parseIndexedReferences(node.fields.ref);
malformed += parsed.malformed;
if (!sourceId || !docIds.includes(sourceId)) continue;
let sourceRefs = refsBySource.get(sourceId);
if (!sourceRefs) {
sourceRefs = new Map();
refsBySource.set(sourceId, sourceRefs);
}
for (const ref of parsed.refs) {
if (ref.docId !== sourceId) sourceRefs.set(ref.docId, ref);
}
}
if (malformed > 0) {
console.warn('[docs-search] skipped malformed references', {
count: malformed,
});
}
return new Map(
docIds.map(sourceId => [
sourceId,
Array.from(refsBySource.get(sourceId)?.values() ?? []).flatMap(
ref => {
const doc = this.docsService.list.doc$(ref.docId).value;
if (!doc) return [];
const params = omit(ref, ['docId']);
return [
{
title: doc.title$.value,
docId: doc.id,
params: isEmpty(params)
? undefined
: toDocSearchParams(params),
},
];
}
),
])
);
return refs
.flatMap(ref => {
const doc = this.docsService.list.doc$(ref.docId).value;
if (!doc) return null;
const title = doc.title$.value;
const params = omit(ref, ['docId']);
return {
title,
docId: doc.id,
params: isEmpty(params)
? undefined
: toDocSearchParams(params),
};
})
.filter(ref => !!ref);
});
}),
// Only propagate downstream when the actual set of linked docs
// changes (a link was added or removed). Without this guard,
// every re-index triggered by typing emits a new array (same
// docs, arbitrary search-engine order) and the navigation panel
// visibly reorders on every keystroke.
//
// Note: this compares docId sets, not order. A stable, meaningful
// sort order (e.g. document appearance order) requires block
// position data from the indexer and is tracked separately.
distinctUntilChanged((prev, curr) => {
if (prev.length !== curr.length) return false;
const currIds = new Set(curr.map(r => r.docId));
return prev.every(r => currIds.has(r.docId));
})
distinctUntilChanged((previous, current) =>
docIds.every(sourceId =>
equalReferenceSets(
previous.get(sourceId) ?? [],
current.get(sourceId) ?? []
)
)
)
);
}
@@ -79,6 +79,12 @@ export class NotificationListService extends Service {
this.loadMore.reset();
}
retry() {
this.error$.setValue(null);
this.loadMore.reset();
this.loadMore();
}
async readNotification(id: string) {
await this.store.readNotification(id);
this.notifications$.next(
@@ -137,7 +137,14 @@ export class WorkspaceMetaImpl implements WorkspaceMeta {
this.docMetaUpdated.next();
}
private _assertValidDocTitle(meta: Partial<DocMeta>) {
if (meta.title !== undefined && typeof meta.title !== 'string') {
throw new TypeError('Document title must be a string');
}
}
addDocMeta(doc: DocMeta, index?: number) {
this._assertValidDocTitle(doc);
this._doc.transact(() => {
if (!this.docs) {
return;
@@ -181,6 +188,7 @@ export class WorkspaceMetaImpl implements WorkspaceMeta {
}
setDocMeta(id: string, props: Partial<DocMeta>) {
this._assertValidDocTitle(props);
const docs = (this.docs as DocMeta[]) ?? [];
const index = docs.findIndex((doc: DocMeta) => id === doc.id);
@@ -0,0 +1,231 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const importDocs = vi.fn();
const docsServiceToken = Symbol('DocsService');
const organizeServiceToken = Symbol('OrganizeService');
vi.mock('../../blocksuite/block-suite-editor', () => ({}));
vi.mock('@blocksuite/affine/widgets/linked-doc', () => ({
ZipTransformer: {
importDocs,
},
}));
vi.mock('@affine/templates/onboarding.zip', () => ({
default: '/onboarding.zip',
}));
vi.mock('../../modules/doc', () => ({
DocsService: docsServiceToken,
}));
vi.mock('../../modules/organize', () => ({
OrganizeService: organizeServiceToken,
}));
vi.mock('../../modules/workspace', () => ({
getAFFiNEWorkspaceSchema: () => 'schema',
}));
const originalBuildConfig = globalThis.BUILD_CONFIG;
beforeEach(() => {
localStorage.clear();
importDocs.mockReset();
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Blob()))
);
vi.stubGlobal('BUILD_CONFIG', {
...originalBuildConfig,
isMobileEdition: false,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
function createWorkspacesService({
existing = [],
createWorkspace,
}: {
existing?: Array<{ id: string; flavour: string }>;
createWorkspace?: (
flavour: string
) => Promise<{ id: string; flavour: string }>;
} = {}) {
const workspaces = [...existing];
const createMock = vi.fn(
createWorkspace ??
(async (flavour: string) => {
const meta = { id: `workspace-${workspaces.length + 1}`, flavour };
workspaces.push(meta);
return meta;
})
);
const waitForDocReady = vi.fn(async () => {});
const create = vi.fn(
async (
flavour: string,
setup: (docCollection: {
meta: { initialize: () => void };
doc: { getMap: () => { set: (key: string, value: string) => void } };
}) => Promise<void>
) => {
const meta = await createMock(flavour);
await setup({
meta: { initialize: vi.fn() },
doc: {
getMap: () => ({
set: vi.fn(),
}),
},
});
return meta;
}
);
const service = {
list: {
['workspaces$']: {
get value() {
return workspaces;
},
},
},
create,
open: ({ metadata }: { metadata: { id: string } }) => ({
workspace: {
id: metadata.id,
engine: {
doc: {
waitForDocReady,
},
},
scope: {
get: (token: symbol) => {
if (token === docsServiceToken) {
return {
list: {
['docs$']: {
value: [
{
id: 'getting-started',
['title$']: { value: 'Getting Started' },
},
],
},
},
};
}
throw new Error('Unexpected service token');
},
},
},
dispose: vi.fn(),
}),
};
return {
service,
createMock,
workspaces,
};
}
describe('createFirstAppData', () => {
test('does not create on desktop when the first-open marker exists', async () => {
localStorage.setItem('is-first-open', 'false');
const { createFirstAppData } = await import('../first-app-data');
const { service, createMock } = createWorkspacesService();
expect(createFirstAppData(service as never)).toBeUndefined();
expect(createMock).not.toHaveBeenCalled();
});
test('creates on mobile when the first-open marker is stale and no workspace exists', async () => {
vi.stubGlobal('BUILD_CONFIG', {
...originalBuildConfig,
isMobileEdition: true,
});
localStorage.setItem('is-first-open', 'false');
const { createFirstAppData } = await import('../first-app-data');
const { service, createMock } = createWorkspacesService();
await expect(createFirstAppData(service as never)).resolves.toMatchObject({
meta: { id: 'workspace-1', flavour: 'local' },
defaultPageId: 'getting-started',
});
expect(createMock).toHaveBeenCalledOnce();
expect(localStorage.getItem('is-first-open')).toBe('false');
});
test('does not create when any workspace already exists', async () => {
vi.stubGlobal('BUILD_CONFIG', {
...originalBuildConfig,
isMobileEdition: true,
});
const { createFirstAppData } = await import('../first-app-data');
const { service, createMock } = createWorkspacesService({
existing: [{ id: 'existing-workspace', flavour: 'local' }],
});
expect(createFirstAppData(service as never)).toBeUndefined();
expect(createMock).not.toHaveBeenCalled();
});
test('coalesces concurrent creation attempts', async () => {
vi.stubGlobal('BUILD_CONFIG', {
...originalBuildConfig,
isMobileEdition: true,
});
const { createFirstAppData } = await import('../first-app-data');
let resolveCreate:
| ((meta: { id: string; flavour: string }) => void)
| undefined;
const { service, createMock, workspaces } = createWorkspacesService({
createWorkspace: flavour =>
new Promise(resolve => {
resolveCreate = meta => {
workspaces.push(meta);
resolve(meta);
};
expect(flavour).toBe('local');
}),
});
const first = createFirstAppData(service as never);
const second = createFirstAppData(service as never);
resolveCreate?.({ id: 'workspace-1', flavour: 'local' });
await expect(Promise.all([first, second])).resolves.toEqual([
{
meta: { id: 'workspace-1', flavour: 'local' },
defaultPageId: 'getting-started',
},
{
meta: { id: 'workspace-1', flavour: 'local' },
defaultPageId: 'getting-started',
},
]);
expect(createMock).toHaveBeenCalledOnce();
});
test('does not persist the first-open marker when creation fails', async () => {
vi.stubGlobal('BUILD_CONFIG', {
...originalBuildConfig,
isMobileEdition: true,
});
const { createFirstAppData } = await import('../first-app-data');
const error = new Error('create failed');
const { service, createMock } = createWorkspacesService({
createWorkspace: async () => {
throw error;
},
});
await expect(createFirstAppData(service as never)).rejects.toThrow(error);
await expect(createFirstAppData(service as never)).rejects.toThrow(error);
expect(createMock).toHaveBeenCalledTimes(2);
expect(localStorage.getItem('is-first-open')).toBeNull();
});
});
@@ -67,16 +67,33 @@ export async function buildShowcaseWorkspace(
const logger = new DebugLogger('createFirstAppData');
export async function createFirstAppData(workspacesService: WorkspacesService) {
if (localStorage.getItem('is-first-open') !== null) {
let firstAppDataPromise:
| Promise<Awaited<ReturnType<typeof buildShowcaseWorkspace>>>
| undefined;
export function createFirstAppData(workspacesService: WorkspacesService) {
if (workspacesService.list.workspaces$.value.length > 0) {
return;
}
localStorage.setItem('is-first-open', 'false');
const { meta, defaultDocId } = await buildShowcaseWorkspace(
if (
!BUILD_CONFIG.isMobileEdition &&
localStorage.getItem('is-first-open') !== null
) {
return;
}
firstAppDataPromise ??= buildShowcaseWorkspace(
workspacesService,
'local',
DEFAULT_WORKSPACE_NAME
);
logger.info('create first workspace', defaultDocId);
return { meta, defaultPageId: defaultDocId };
).finally(() => {
firstAppDataPromise = undefined;
});
return firstAppDataPromise.then(({ meta, defaultDocId }) => {
localStorage.setItem('is-first-open', 'false');
logger.info('create first workspace', defaultDocId);
return { meta, defaultPageId: defaultDocId };
});
}