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
@@ -43,3 +43,8 @@ export const hiddenInput = style({
height: '0',
position: 'absolute',
});
export const timeRow = style({
marginTop: 20,
borderBottom: 4,
});
@@ -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 ? (
<>
@@ -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>
@@ -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': {
@@ -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 />
@@ -46,6 +46,4 @@ export const rowCell = style({
export const container = style({
display: 'flex',
flexDirection: 'column',
marginTop: 20,
marginBottom: 4,
});
@@ -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>
);
};
@@ -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`,
},
]);
@@ -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}
@@ -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>
);
};
@@ -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;
};
@@ -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();
@@ -45,5 +45,5 @@ export const prefix = style({
export const suffix = style({
display: 'flex',
alignItems: 'center',
gap: 18,
gap: 6,
});
@@ -1,10 +0,0 @@
import { PageHeader } from '../../components/page-header';
export const Component = () => {
return (
<>
<PageHeader back />
<div>/workspace/:workspaceId/:pageId</div>;
</>
);
};
@@ -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>
);
};
@@ -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',
});
@@ -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>
);
};
@@ -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',
});
@@ -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>
);
};
@@ -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',
});
@@ -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>
);
};
+1 -1
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: '*',