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

@@ -43,3 +43,8 @@ export const hiddenInput = style({
height: '0',
position: 'absolute',
});
export const timeRow = style({
marginTop: 20,
borderBottom: 4,
});

View File

@@ -78,7 +78,7 @@ export const InfoModal = ({
);
};
const InfoTable = ({
export const InfoTable = ({
onClose,
docId,
readonly,
@@ -106,8 +106,8 @@ const InfoTable = ({
);
return (
<div>
<TimeRow docId={docId} />
<div className={styles.container}>
<TimeRow className={styles.timeRow} docId={docId} />
<Divider size="thinner" />
{backlinks && backlinks.length > 0 ? (
<>

View File

@@ -8,15 +8,17 @@ import * as styles from './links-row.css';
export const LinksRow = ({
references,
label,
className,
onClick,
}: {
references: Backlink[] | Link[];
label: string;
className?: string;
onClick?: () => void;
}) => {
const manager = useContext(managerContext);
return (
<div>
<div className={className}>
<div className={styles.title}>
{label} · {references.length}
</div>

View File

@@ -1,5 +1,7 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
import { fallbackVar, style } from '@vanilla-extract/css';
import { rowHPadding } from '../styles.css';
export const icon = style({
fontSize: 16,
@@ -65,7 +67,7 @@ export const rowValueCell = style({
':hover': {
backgroundColor: cssVar('hoverColor'),
},
padding: '6px 8px',
padding: `6px ${fallbackVar(rowHPadding, '8px')} 6px 8px`,
border: `1px solid transparent`,
color: cssVar('textPrimaryColor'),
':focus': {

View File

@@ -11,16 +11,21 @@ import * as styles from './tags-row.css';
export const TagsRow = ({
docId,
readonly,
className,
}: {
docId: string;
readonly: boolean;
className?: string;
}) => {
const t = useI18n();
const tagList = useService(TagService).tagList;
const tagIds = useLiveData(tagList.tagIdsByPageId$(docId));
const empty = !tagIds || tagIds.length === 0;
return (
<div className={styles.rowCell} data-testid="info-modal-tags-row">
<div
className={clsx(styles.rowCell, className)}
data-testid="info-modal-tags-row"
>
<div className={styles.rowNameContainer}>
<div className={styles.icon}>
<TagsIcon />

View File

@@ -46,6 +46,4 @@ export const rowCell = style({
export const container = style({
display: 'flex',
flexDirection: 'column',
marginTop: 20,
marginBottom: 4,
});

View File

@@ -1,6 +1,7 @@
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import type { ConfigType } from 'dayjs';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { type ReactNode, useContext, useMemo } from 'react';
@@ -28,7 +29,13 @@ const RowComponent = ({
);
};
export const TimeRow = ({ docId }: { docId: string }) => {
export const TimeRow = ({
docId,
className,
}: {
docId: string;
className?: string;
}) => {
const t = useI18n();
const manager = useContext(managerContext);
const workspaceService = useService(WorkspaceService);
@@ -88,5 +95,7 @@ export const TimeRow = ({ docId }: { docId: string }) => {
const dTimestampElement = useDebouncedValue(timestampElement, 500);
return <div className={styles.container}>{dTimestampElement}</div>;
return (
<div className={clsx(styles.container, className)}>{dTimestampElement}</div>
);
};

View File

@@ -2,6 +2,8 @@ import { cssVar } from '@toeverything/theme';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
const propertyNameCellWidth = createVar();
export const rowHPadding = createVar();
export const fontSize = createVar();
export const root = style({
display: 'flex',
@@ -10,6 +12,16 @@ export const root = style({
fontFamily: cssVar('fontSansFamily'),
vars: {
[propertyNameCellWidth]: '160px',
[rowHPadding]: '6px',
[fontSize]: cssVar('fontSm'),
},
'@container': {
[`viewport (width <= 640px)`]: {
vars: {
[rowHPadding]: '0px',
[fontSize]: cssVar('fontXs'),
},
},
},
});
@@ -22,7 +34,7 @@ export const rootCentered = style({
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 24px',
padding: '0 16px',
},
},
});
@@ -39,7 +51,7 @@ export const tableHeaderInfoRow = style({
justifyContent: 'space-between',
alignItems: 'center',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontSize: fontSize,
fontWeight: 500,
minHeight: 34,
'@media': {
@@ -54,9 +66,9 @@ export const tableHeaderSecondaryRow = style({
flexDirection: 'row',
alignItems: 'center',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontSize: fontSize,
fontWeight: 500,
padding: '0 6px',
padding: `0 ${rowHPadding}`,
gap: '8px',
height: 24,
'@media': {
@@ -82,7 +94,7 @@ export const spacer = style({
});
export const tableHeaderBacklinksHint = style({
padding: '6px',
padding: `0 ${rowHPadding}`,
cursor: 'pointer',
borderRadius: '4px',
':hover': {
@@ -103,7 +115,7 @@ export const tableHeaderTimestamp = style({
alignItems: 'start',
gap: '8px',
cursor: 'default',
padding: '0 6px',
padding: `0 ${rowHPadding}`,
});
export const tableHeaderDivider = style({
@@ -273,7 +285,7 @@ export const editablePropertyRowCell = style([
export const propertyRowNameCell = style([
propertyRowCell,
{
padding: 6,
padding: `6px ${rowHPadding}`,
flexShrink: 0,
color: cssVar('textSecondaryColor'),
width: propertyNameCellWidth,
@@ -316,7 +328,7 @@ export const propertyRowValueCell = style([
propertyRowCell,
editablePropertyRowCell,
{
padding: '6px 8px',
padding: `6px ${rowHPadding} 6px 6px`,
border: `1px solid transparent`,
color: cssVar('textPrimaryColor'),
':focus': {
@@ -353,7 +365,7 @@ export const propertyRowValueTextarea = style([
propertyRowValueCell,
{
border: 'none',
padding: '6px 8px',
padding: `6px ${rowHPadding} 6px 8px`,
height: '100%',
position: 'absolute',
top: 0,
@@ -368,7 +380,7 @@ export const propertyRowValueTextareaInvisible = style([
propertyRowValueCell,
{
border: 'none',
padding: '6px 8px',
padding: `6px ${rowHPadding} 6px 8px`,
visibility: 'hidden',
whiteSpace: 'break-spaces',
wordBreak: 'break-all',
@@ -379,7 +391,7 @@ export const propertyRowValueTextareaInvisible = style([
export const propertyRowValueNumberCell = style([
propertyRowValueTextCell,
{
padding: '6px 8px',
padding: `6px ${rowHPadding} 6px 8px`,
},
]);

View File

@@ -191,7 +191,7 @@ export const BlocksuiteDocEditor = forwardRef<
) : (
<BlocksuiteEditorJournalDocTitle page={page} />
)}
<PagePropertiesTable docId={page.id} />
{!shared ? <PagePropertiesTable docId={page.id} /> : null}
<adapted.DocEditor
className={styles.docContainer}
ref={onDocRef}

View File

@@ -0,0 +1,136 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { Editor } from '@affine/core/modules/editor';
import { EditorsService } from '@affine/core/modules/editor';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { DocMode } from '@blocksuite/blocks';
import type { Doc } from '@toeverything/infra';
import {
DocsService,
FrameworkScope,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { PageNotFound } from '../../404';
const useLoadDoc = (pageId: string) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const docRecordList = docsService.list;
const docListReady = useLiveData(docRecordList.isReady$);
const docRecord = useLiveData(docRecordList.doc$(pageId));
const viewService = useService(ViewService);
const queryString = useLiveData(
viewService.view.queryString$<{
mode?: string;
}>()
);
const queryStringMode =
queryString.mode && ['edgeless', 'page'].includes(queryString.mode)
? (queryString.mode as DocMode)
: null;
// We only read the querystring mode when entering, so use useState here.
const [initialQueryStringMode] = useState(() => queryStringMode);
const [doc, setDoc] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const editorMode = useLiveData(editor?.mode$);
useLayoutEffect(() => {
if (!docRecord) {
return;
}
const { doc: opened, release } = docsService.open(pageId);
setDoc(opened);
return () => {
release();
};
}, [docRecord, docsService, pageId]);
useLayoutEffect(() => {
if (!doc) {
return;
}
const editor = doc.scope
.get(EditorsService)
.createEditor(initialQueryStringMode || doc.getPrimaryMode() || 'page');
setEditor(editor);
return () => {
editor.dispose();
};
}, [doc, initialQueryStringMode]);
// update editor mode to queryString
useEffect(() => {
if (editorMode) {
viewService.view.updateQueryString(
{
mode: editorMode,
},
{
replace: true,
}
);
}
}, [editorMode, viewService.view]);
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
}, [currentWorkspace, pageId]);
const isInTrash = useLiveData(doc?.meta$.map(meta => meta.trash));
useEffect(() => {
if (doc && isInTrash) {
currentWorkspace.docCollection.awarenessStore.setReadonly(
doc.blockSuiteDoc.blockCollection,
true
);
}
}, [currentWorkspace.docCollection.awarenessStore, doc, isInTrash]);
return {
doc,
editor,
docListReady,
};
};
/**
* A common wrapper for detail page for both mobile and desktop page.
* It only contains the logic for page loading, context setup, but not the page content.
*/
export const DetailPageWrapper = ({
pageId,
children,
}: PropsWithChildren<{ pageId: string }>) => {
const { doc, editor, docListReady } = useLoadDoc(pageId);
// if sync engine has been synced and the page is null, show 404 page.
if (docListReady && !doc) {
return <PageNotFound noPermission />;
}
if (!doc || !editor) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
return (
<FrameworkScope scope={doc.scope}>
<FrameworkScope scope={editor.scope}>{children}</FrameworkScope>
</FrameworkScope>
);
};

View File

@@ -1,16 +1,14 @@
import { Scrollable, useHasScrollTop } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import type { Editor } from '@affine/core/modules/editor';
import { EditorService, EditorsService } from '@affine/core/modules/editor';
import { EditorService } from '@affine/core/modules/editor';
import { RecentDocsService } from '@affine/core/modules/quicksearch';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { DocMode, PageRootService } from '@blocksuite/blocks';
import type { PageRootService } from '@blocksuite/blocks';
import {
BookmarkBlockService,
customImageProxyMiddleware,
@@ -23,10 +21,8 @@ import { DisposableGroup } from '@blocksuite/global/utils';
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
import { type AffineEditorContainer } from '@blocksuite/presets';
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
import type { Doc } from '@toeverything/infra';
import {
DocService,
DocsService,
FrameworkScope,
globalBlockSuiteSchema,
GlobalContextService,
@@ -36,15 +32,7 @@ import {
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import type { ReactElement } from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
@@ -65,9 +53,9 @@ import {
WorkbenchService,
} from '../../../modules/workbench';
import { performanceRenderLogger } from '../../../shared';
import { PageNotFound } from '../../404';
import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { DetailPageWrapper } from './detail-page-wrapper';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
@@ -330,107 +318,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
);
});
export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const docRecordList = docsService.list;
const docListReady = useLiveData(docRecordList.isReady$);
const docRecord = useLiveData(docRecordList.doc$(pageId));
const viewService = useService(ViewService);
const queryString = useLiveData(
viewService.view.queryString$<{
mode?: string;
}>()
);
const queryStringMode =
queryString.mode && ['edgeless', 'page'].includes(queryString.mode)
? (queryString.mode as DocMode)
: null;
// We only read the querystring mode when entering, so use useState here.
const [initialQueryStringMode] = useState(() => queryStringMode);
const [doc, setDoc] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const editorMode = useLiveData(editor?.mode$);
useLayoutEffect(() => {
if (!docRecord) {
return;
}
const { doc: opened, release } = docsService.open(pageId);
setDoc(opened);
return () => {
release();
};
}, [docRecord, docsService, pageId]);
useLayoutEffect(() => {
if (!doc) {
return;
}
const editor = doc.scope
.get(EditorsService)
.createEditor(initialQueryStringMode || doc.getPrimaryMode() || 'page');
setEditor(editor);
return () => {
editor.dispose();
};
}, [doc, initialQueryStringMode]);
// update editor mode to queryString
useEffect(() => {
if (editorMode) {
viewService.view.updateQueryString(
{
mode: editorMode,
},
{
replace: true,
}
);
}
}, [editorMode, viewService.view]);
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
}, [currentWorkspace, pageId]);
const isInTrash = useLiveData(doc?.meta$.map(meta => meta.trash));
useEffect(() => {
if (doc && isInTrash) {
currentWorkspace.docCollection.awarenessStore.setReadonly(
doc.blockSuiteDoc.blockCollection,
true
);
}
}, [currentWorkspace.docCollection.awarenessStore, doc, isInTrash]);
// if sync engine has been synced and the page is null, show 404 page.
if (docListReady && !doc) {
return <PageNotFound noPermission />;
}
if (!doc || !editor) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
return (
<FrameworkScope scope={doc.scope}>
<FrameworkScope scope={editor.scope}>
<DetailPageImpl />
</FrameworkScope>
</FrameworkScope>
);
};
export const Component = () => {
performanceRenderLogger.debug('DetailPage');
@@ -448,5 +335,9 @@ export const Component = () => {
const pageId = params.pageId;
return pageId ? <DetailPage pageId={pageId} /> : null;
return pageId ? (
<DetailPageWrapper pageId={pageId}>
<DetailPageImpl />
</DetailPageWrapper>
) : null;
};

View File

@@ -4,6 +4,10 @@ export function stopPropagation(event: BaseSyntheticEvent) {
event.stopPropagation();
}
export function preventDefault(event: BaseSyntheticEvent) {
event.preventDefault();
}
export function stopEvent(event: BaseSyntheticEvent) {
event.stopPropagation();
event.preventDefault();