feat(mobile): add mobile detail page (#7993)

fix AF-1241
This commit is contained in:
pengx17
2024-08-29 16:45:22 +00:00
parent f8e6f1f2b5
commit 7ae141bd9e
22 changed files with 865 additions and 153 deletions

View File

@@ -45,5 +45,5 @@ export const prefix = style({
export const suffix = style({
display: 'flex',
alignItems: 'center',
gap: 18,
gap: 6,
});

View File

@@ -1,10 +0,0 @@
import { PageHeader } from '../../components/page-header';
export const Component = () => {
return (
<>
<PageHeader back />
<div>/workspace/:workspaceId/:pageId</div>;
</>
);
};

View File

@@ -0,0 +1,43 @@
import { IconButton, MobileMenu } from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { EditorJournalPanel } from '@affine/core/pages/workspace/detail-page/tabs/journal';
import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
export const JournalIconButton = ({
docId,
className,
}: {
docId: string;
className?: string;
}) => {
const workspace = useService(WorkspaceService).workspace;
const { journalDate, isJournal } = useJournalInfoHelper(
workspace.docCollection,
docId
);
const Icon = journalDate
? journalDate.isBefore(new Date(), 'day')
? YesterdayIcon
: journalDate.isAfter(new Date(), 'day')
? TomorrowIcon
: TodayIcon
: TodayIcon;
if (!isJournal) {
return null;
}
return (
<MobileMenu
items={<EditorJournalPanel />}
contentOptions={{
align: 'center',
}}
>
<IconButton className={className} size={24}>
<Icon />
</IconButton>
</MobileMenu>
);
};

View File

@@ -0,0 +1,91 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
background: cssVarV2('layer/background/primary'),
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
});
export const header = style({
background: cssVarV2('layer/background/primary'),
position: 'sticky',
top: 0,
zIndex: 1,
});
export const mainContainer = style({
containerType: 'inline-size',
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden',
borderTop: `0.5px solid transparent`,
transition: 'border-color 0.2s',
selectors: {
'&[data-dynamic-top-border="false"]': {
borderColor: cssVar('borderColor'),
},
'&[data-has-scroll-top="true"]': {
borderColor: cssVar('borderColor'),
},
},
});
export const editorContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
flex: 1,
zIndex: 0,
});
// brings styles of .affine-page-viewport from blocksuite
export const affineDocViewport = style({
display: 'flex',
flexDirection: 'column',
containerName: 'viewport',
containerType: 'inline-size',
background: cssVarV2('layer/background/primary'),
selectors: {
'&[data-mode="edgeless"]': {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
},
});
export const scrollbar = style({
marginRight: '4px',
});
globalStyle('.doc-title-container', {
fontSize: cssVar('fontH1'),
'@container': {
[`viewport (width <= 640px)`]: {
padding: '10px 16px',
lineHeight: '38px',
},
},
});
globalStyle('.affine-page-root-block-container', {
'@container': {
[`viewport (width <= 640px)`]: {
paddingLeft: 16,
paddingRight: 16,
},
},
});
export const journalIconButton = style({
position: 'absolute',
zIndex: 1,
top: 16,
right: 12,
display: 'flex',
});

View File

@@ -0,0 +1,225 @@
import { IconButton } from '@affine/component';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { useRegisterBlocksuiteEditorCommands } from '@affine/core/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { EditorService } from '@affine/core/modules/editor';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { DetailPageWrapper } from '@affine/core/pages/workspace/detail-page/detail-page-wrapper';
import type { PageRootService } from '@blocksuite/blocks';
import {
BookmarkBlockService,
customImageProxyMiddleware,
EmbedGithubBlockService,
EmbedLoomBlockService,
EmbedYoutubeBlockService,
ImageBlockService,
} from '@blocksuite/blocks';
import { DisposableGroup } from '@blocksuite/global/utils';
import { ShareIcon } from '@blocksuite/icons/rc';
import { type AffineEditorContainer } from '@blocksuite/presets';
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
import {
DocService,
FrameworkScope,
GlobalContextService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { PageHeader } from '../../../components';
import { JournalIconButton } from './journal-icon-button';
import * as styles from './mobile-detail-page.css';
import { PageHeaderMenuButton } from './page-header-more-button';
const DetailPageImpl = () => {
const { editorService, docService, workspaceService, globalContextService } =
useServices({
WorkbenchService,
ViewService,
EditorService,
DocService,
WorkspaceService,
GlobalContextService,
});
const editor = editorService.editor;
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const globalContext = globalContextService.globalContext;
const doc = docService.doc;
const mode = useLiveData(editor.mode$);
const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash));
const { openPage, jumpToPageBlock, jumpToTag } = useNavigateHelper();
const editorContainer = useLiveData(editor.editorContainer$);
const { setDocReadonly } = useDocMetaHelper(workspace.docCollection);
// TODO(@eyhn): remove jotai here
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
useEffect(() => {
setActiveBlockSuiteEditor(editorContainer);
}, [editorContainer, setActiveBlockSuiteEditor]);
useEffect(() => {
globalContext.docId.set(doc.id);
globalContext.isDoc.set(true);
return () => {
globalContext.docId.set(null);
globalContext.isDoc.set(false);
};
}, [doc, globalContext]);
useEffect(() => {
globalContext.docMode.set(mode);
return () => {
globalContext.docMode.set(null);
};
}, [doc, globalContext, mode]);
useEffect(() => {
setDocReadonly(doc.id, true);
}, [doc.id, setDocReadonly]);
useEffect(() => {
globalContext.isTrashDoc.set(!!isInTrash);
return () => {
globalContext.isTrashDoc.set(null);
};
}, [globalContext, isInTrash]);
useRegisterBlocksuiteEditorCommands(editor);
const title = useLiveData(doc.title$);
usePageDocumentTitle(title);
const onLoad = useCallback(
(_bsPage: BlockSuiteDoc, editorContainer: AffineEditorContainer) => {
// blocksuite editor host
const editorHost = editorContainer.host;
// provide image proxy endpoint to blocksuite
editorHost?.std.clipboard.use(
customImageProxyMiddleware(runtimeConfig.imageProxyUrl)
);
ImageBlockService.setImageProxyURL(runtimeConfig.imageProxyUrl);
// provide link preview endpoint to blocksuite
BookmarkBlockService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
EmbedGithubBlockService.setLinkPreviewEndpoint(
runtimeConfig.linkPreviewUrl
);
EmbedYoutubeBlockService.setLinkPreviewEndpoint(
runtimeConfig.linkPreviewUrl
);
EmbedLoomBlockService.setLinkPreviewEndpoint(
runtimeConfig.linkPreviewUrl
);
// provide page mode and updated date to blocksuite
const pageService =
editorHost?.std.spec.getService<PageRootService>('affine:page');
const disposable = new DisposableGroup();
if (pageService) {
disposable.add(
pageService.slots.docLinkClicked.on(({ docId, blockId }) => {
return blockId
? jumpToPageBlock(docCollection.id, docId, blockId)
: openPage(docCollection.id, docId);
})
);
disposable.add(
pageService.slots.tagClicked.on(({ tagId }) => {
jumpToTag(workspace.id, tagId);
})
);
}
editor.setEditorContainer(editorContainer);
return () => {
disposable.dispose();
};
},
[
editor,
jumpToPageBlock,
docCollection.id,
openPage,
jumpToTag,
workspace.id,
]
);
return (
<FrameworkScope scope={editor.scope}>
<div className={styles.mainContainer}>
<div
data-mode={mode}
className={clsx(
'affine-page-viewport',
styles.affineDocViewport,
styles.editorContainer
)}
>
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id}>
<JournalIconButton
docId={doc.id}
className={styles.journalIconButton}
/>
<PageDetailEditor
pageId={doc.id}
onLoad={onLoad}
docCollection={docCollection}
/>
</AffineErrorBoundary>
</div>
</div>
</FrameworkScope>
);
};
export const Component = () => {
const params = useParams();
const pageId = params.pageId;
if (!pageId) {
return null;
}
return (
<div className={styles.root}>
<DetailPageWrapper pageId={pageId}>
<PageHeader
back
className={styles.header}
suffix={
<>
<IconButton
size={24}
style={{ padding: 10 }}
onClick={console.log}
icon={<ShareIcon />}
/>
<PageHeaderMenuButton docId={pageId} />
</>
}
/>
<DetailPageImpl />
</DetailPageWrapper>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const iconButton = style({
selectors: {
'&[data-state=open]': {
backgroundColor: cssVar('hoverColor'),
},
},
padding: '10px',
});
export const outlinePanel = style({
maxHeight: '60vh',
overflow: 'auto',
});

View File

@@ -0,0 +1,132 @@
import { IconButton, toast } from '@affine/component';
import {
MenuSeparator,
MobileMenu,
MobileMenuItem,
} from '@affine/component/ui/menu';
import { useFavorite } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { track } from '@affine/core/mixpanel';
import { EditorService } from '@affine/core/modules/editor';
import { EditorOutlinePanel } from '@affine/core/pages/workspace/detail-page/tabs/outline';
import { preventDefault } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import {
EdgelessIcon,
InformationIcon,
MoreHorizontalIcon,
PageIcon,
TocIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import * as styles from './page-header-more-button.css';
import { DocInfoSheet } from './sheets/doc-info';
type PageMenuProps = {
docId: string;
};
export const PageHeaderMenuButton = ({ docId }: PageMenuProps) => {
const t = useI18n();
const editorService = useService(EditorService);
const editorContainer = useLiveData(editorService.editor.editorContainer$);
const isInTrash = useLiveData(
editorService.editor.doc.meta$.map(meta => meta.trash)
);
const currentMode = useLiveData(editorService.editor.mode$);
const { favorite, toggleFavorite } = useFavorite(docId);
const handleSwitchMode = useCallback(() => {
editorService.editor.toggleMode();
track.$.header.docOptions.switchPageMode({
mode: currentMode === 'page' ? 'edgeless' : 'page',
});
toast(
currentMode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']()
);
}, [currentMode, editorService, t]);
const handleMenuOpenChange = useCallback((open: boolean) => {
if (open) {
track.$.header.docOptions.open();
}
}, []);
const handleToggleFavorite = useCallback(() => {
track.$.header.docOptions.toggleFavorite();
toggleFavorite();
}, [toggleFavorite]);
const EditMenu = (
<>
<MobileMenuItem
prefixIcon={currentMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-edgeless"
onSelect={handleSwitchMode}
>
{t['Convert to ']()}
{currentMode === 'page'
? t['com.affine.pageMode.edgeless']()
: t['com.affine.pageMode.page']()}
</MobileMenuItem>
<MobileMenuItem
data-testid="editor-option-menu-favorite"
onSelect={handleToggleFavorite}
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
>
{favorite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
</MobileMenuItem>
<MenuSeparator />
<MobileMenu items={<DocInfoSheet docId={docId} />}>
<MobileMenuItem
prefixIcon={<InformationIcon />}
onClick={preventDefault}
>
<span>{t['com.affine.page-properties.page-info.view']()}</span>
</MobileMenuItem>
</MobileMenu>
<MobileMenu
items={
<div className={styles.outlinePanel}>
<EditorOutlinePanel editor={editorContainer} />
</div>
}
>
<MobileMenuItem prefixIcon={<TocIcon />} onClick={preventDefault}>
<span>{t['com.affine.header.option.view-toc']()}</span>
</MobileMenuItem>
</MobileMenu>
</>
);
if (isInTrash) {
return null;
}
return (
<MobileMenu
items={EditMenu}
contentOptions={{
align: 'center',
}}
rootOptions={{
onOpenChange: handleMenuOpenChange,
}}
>
<IconButton
size={24}
data-testid="header-dropDownButton"
className={styles.iconButton}
>
<MoreHorizontalIcon />
</IconButton>
</MobileMenu>
);
};

View File

@@ -0,0 +1,51 @@
import { rowHPadding } from '@affine/core/components/affine/page-properties/styles.css';
import { style } from '@vanilla-extract/css';
export const viewport = style({
vars: {
[rowHPadding]: '0px',
},
});
export const item = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
height: 34,
padding: '0 20px',
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
});
export const linksRow = style({
padding: '0 16px',
});
export const timeRow = style({
padding: '0 16px',
});
export const tagsRow = style({
padding: '0 16px',
});
export const properties = style({
padding: '0 16px',
});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 6,
padding: 6,
width: '160px',
});

View File

@@ -0,0 +1,100 @@
import { Divider, Scrollable } from '@affine/component';
import {
PagePropertyRow,
SortableProperties,
usePagePropertiesManager,
} from '@affine/core/components/affine/page-properties';
import { managerContext } from '@affine/core/components/affine/page-properties/common';
import { LinksRow } from '@affine/core/components/affine/page-properties/info-modal/links-row';
import { TagsRow } from '@affine/core/components/affine/page-properties/info-modal/tags-row';
import { TimeRow } from '@affine/core/components/affine/page-properties/info-modal/time-row';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { Suspense, useMemo } from 'react';
import * as styles from './doc-info.css';
export const DocInfoSheet = ({ docId }: { docId: string }) => {
const manager = usePagePropertiesManager(docId);
const docsSearchService = useService(DocsSearchService);
const t = useI18n();
const links = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
[docId, docsSearchService]
)
);
const backlinks = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsTo(docId), null),
[docId, docsSearchService]
)
);
return (
<Scrollable.Root>
<Scrollable.Viewport
className={styles.viewport}
data-testid="doc-info-menu"
>
<managerContext.Provider value={manager}>
<Suspense>
<TimeRow docId={docId} className={styles.timeRow} />
<Divider size="thinner" />
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={backlinks}
label={t['com.affine.page-properties.backlinks']()}
/>
<Divider size="thinner" />
</>
) : null}
{links && links.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={links}
label={t['com.affine.page-properties.outgoing-links']()}
/>
<Divider size="thinner" />
</>
) : null}
<div className={styles.properties}>
<TagsRow docId={docId} readonly={manager.readonly} />
<SortableProperties>
{properties =>
properties.length ? (
<>
{properties
.filter(
property =>
manager.isPropertyRequired(property.id) ||
(property.visibility !== 'hide' &&
!(
property.visibility === 'hide-if-empty' &&
!property.value
))
)
.map(property => (
<PagePropertyRow
key={property.id}
property={property}
rowNameClassName={styles.rowNameContainer}
/>
))}
</>
) : null
}
</SortableProperties>
</div>
</Suspense>
</managerContext.Provider>
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />
</Scrollable.Root>
);
};

View File

@@ -95,7 +95,7 @@ export const viewRoutes = [
},
{
path: '/:pageId',
lazy: () => import('./pages/workspace/detail'),
lazy: () => import('./pages/workspace/detail/mobile-detail-page'),
},
{
path: '*',