mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 05:48:59 +08:00
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:
@@ -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';
|
||||
|
||||
+12
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
+20
-18
@@ -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
|
||||
|
||||
+67
-43
@@ -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>
|
||||
);
|
||||
};
|
||||
+93
@@ -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))
|
||||
);
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { createIdentifier } from '@toeverything/infra';
|
||||
|
||||
export interface NavigationGestureProvider {
|
||||
isEnabled: () => boolean;
|
||||
enable: () => void;
|
||||
disable: () => void;
|
||||
}
|
||||
|
||||
export const NavigationGestureProvider =
|
||||
createIdentifier<NavigationGestureProvider>('NavigationGestureProvider');
|
||||
-57
@@ -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,
|
||||
]);
|
||||
}
|
||||
+214
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
+143
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+293
@@ -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();
|
||||
});
|
||||
});
|
||||
+513
@@ -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}
|
||||
|
||||
+6
-4
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user