@@ -43,3 +43,8 @@ export const hiddenInput = style({
|
|||||||
height: '0',
|
height: '0',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const timeRow = style({
|
||||||
|
marginTop: 20,
|
||||||
|
borderBottom: 4,
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const InfoModal = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InfoTable = ({
|
export const InfoTable = ({
|
||||||
onClose,
|
onClose,
|
||||||
docId,
|
docId,
|
||||||
readonly,
|
readonly,
|
||||||
@@ -106,8 +106,8 @@ const InfoTable = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<TimeRow docId={docId} />
|
<TimeRow className={styles.timeRow} docId={docId} />
|
||||||
<Divider size="thinner" />
|
<Divider size="thinner" />
|
||||||
{backlinks && backlinks.length > 0 ? (
|
{backlinks && backlinks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import * as styles from './links-row.css';
|
|||||||
export const LinksRow = ({
|
export const LinksRow = ({
|
||||||
references,
|
references,
|
||||||
label,
|
label,
|
||||||
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
references: Backlink[] | Link[];
|
references: Backlink[] | Link[];
|
||||||
label: string;
|
label: string;
|
||||||
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const manager = useContext(managerContext);
|
const manager = useContext(managerContext);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
{label} · {references.length}
|
{label} · {references.length}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
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({
|
export const icon = style({
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -65,7 +67,7 @@ export const rowValueCell = style({
|
|||||||
':hover': {
|
':hover': {
|
||||||
backgroundColor: cssVar('hoverColor'),
|
backgroundColor: cssVar('hoverColor'),
|
||||||
},
|
},
|
||||||
padding: '6px 8px',
|
padding: `6px ${fallbackVar(rowHPadding, '8px')} 6px 8px`,
|
||||||
border: `1px solid transparent`,
|
border: `1px solid transparent`,
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
':focus': {
|
':focus': {
|
||||||
|
|||||||
@@ -11,16 +11,21 @@ import * as styles from './tags-row.css';
|
|||||||
export const TagsRow = ({
|
export const TagsRow = ({
|
||||||
docId,
|
docId,
|
||||||
readonly,
|
readonly,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
docId: string;
|
docId: string;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
|
className?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tagList = useService(TagService).tagList;
|
const tagList = useService(TagService).tagList;
|
||||||
const tagIds = useLiveData(tagList.tagIdsByPageId$(docId));
|
const tagIds = useLiveData(tagList.tagIdsByPageId$(docId));
|
||||||
const empty = !tagIds || tagIds.length === 0;
|
const empty = !tagIds || tagIds.length === 0;
|
||||||
return (
|
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.rowNameContainer}>
|
||||||
<div className={styles.icon}>
|
<div className={styles.icon}>
|
||||||
<TagsIcon />
|
<TagsIcon />
|
||||||
|
|||||||
@@ -46,6 +46,4 @@ export const rowCell = style({
|
|||||||
export const container = style({
|
export const container = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
marginTop: 20,
|
|
||||||
marginBottom: 4,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { i18nTime, useI18n } from '@affine/i18n';
|
import { i18nTime, useI18n } from '@affine/i18n';
|
||||||
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
|
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||||
|
import clsx from 'clsx';
|
||||||
import type { ConfigType } from 'dayjs';
|
import type { ConfigType } from 'dayjs';
|
||||||
import { useDebouncedValue } from 'foxact/use-debounced-value';
|
import { useDebouncedValue } from 'foxact/use-debounced-value';
|
||||||
import { type ReactNode, useContext, useMemo } from 'react';
|
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 t = useI18n();
|
||||||
const manager = useContext(managerContext);
|
const manager = useContext(managerContext);
|
||||||
const workspaceService = useService(WorkspaceService);
|
const workspaceService = useService(WorkspaceService);
|
||||||
@@ -88,5 +95,7 @@ export const TimeRow = ({ docId }: { docId: string }) => {
|
|||||||
|
|
||||||
const dTimestampElement = useDebouncedValue(timestampElement, 500);
|
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';
|
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
const propertyNameCellWidth = createVar();
|
const propertyNameCellWidth = createVar();
|
||||||
|
export const rowHPadding = createVar();
|
||||||
|
export const fontSize = createVar();
|
||||||
|
|
||||||
export const root = style({
|
export const root = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -10,6 +12,16 @@ export const root = style({
|
|||||||
fontFamily: cssVar('fontSansFamily'),
|
fontFamily: cssVar('fontSansFamily'),
|
||||||
vars: {
|
vars: {
|
||||||
[propertyNameCellWidth]: '160px',
|
[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')}`,
|
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||||
'@container': {
|
'@container': {
|
||||||
[`viewport (width <= 640px)`]: {
|
[`viewport (width <= 640px)`]: {
|
||||||
padding: '0 24px',
|
padding: '0 16px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -39,7 +51,7 @@ export const tableHeaderInfoRow = style({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVar('textSecondaryColor'),
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: fontSize,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
minHeight: 34,
|
minHeight: 34,
|
||||||
'@media': {
|
'@media': {
|
||||||
@@ -54,9 +66,9 @@ export const tableHeaderSecondaryRow = style({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: fontSize,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
padding: '0 6px',
|
padding: `0 ${rowHPadding}`,
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
height: 24,
|
height: 24,
|
||||||
'@media': {
|
'@media': {
|
||||||
@@ -82,7 +94,7 @@ export const spacer = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const tableHeaderBacklinksHint = style({
|
export const tableHeaderBacklinksHint = style({
|
||||||
padding: '6px',
|
padding: `0 ${rowHPadding}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
':hover': {
|
':hover': {
|
||||||
@@ -103,7 +115,7 @@ export const tableHeaderTimestamp = style({
|
|||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
padding: '0 6px',
|
padding: `0 ${rowHPadding}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tableHeaderDivider = style({
|
export const tableHeaderDivider = style({
|
||||||
@@ -273,7 +285,7 @@ export const editablePropertyRowCell = style([
|
|||||||
export const propertyRowNameCell = style([
|
export const propertyRowNameCell = style([
|
||||||
propertyRowCell,
|
propertyRowCell,
|
||||||
{
|
{
|
||||||
padding: 6,
|
padding: `6px ${rowHPadding}`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVar('textSecondaryColor'),
|
||||||
width: propertyNameCellWidth,
|
width: propertyNameCellWidth,
|
||||||
@@ -316,7 +328,7 @@ export const propertyRowValueCell = style([
|
|||||||
propertyRowCell,
|
propertyRowCell,
|
||||||
editablePropertyRowCell,
|
editablePropertyRowCell,
|
||||||
{
|
{
|
||||||
padding: '6px 8px',
|
padding: `6px ${rowHPadding} 6px 6px`,
|
||||||
border: `1px solid transparent`,
|
border: `1px solid transparent`,
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
':focus': {
|
':focus': {
|
||||||
@@ -353,7 +365,7 @@ export const propertyRowValueTextarea = style([
|
|||||||
propertyRowValueCell,
|
propertyRowValueCell,
|
||||||
{
|
{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: '6px 8px',
|
padding: `6px ${rowHPadding} 6px 8px`,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -368,7 +380,7 @@ export const propertyRowValueTextareaInvisible = style([
|
|||||||
propertyRowValueCell,
|
propertyRowValueCell,
|
||||||
{
|
{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: '6px 8px',
|
padding: `6px ${rowHPadding} 6px 8px`,
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
whiteSpace: 'break-spaces',
|
whiteSpace: 'break-spaces',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
@@ -379,7 +391,7 @@ export const propertyRowValueTextareaInvisible = style([
|
|||||||
export const propertyRowValueNumberCell = style([
|
export const propertyRowValueNumberCell = style([
|
||||||
propertyRowValueTextCell,
|
propertyRowValueTextCell,
|
||||||
{
|
{
|
||||||
padding: '6px 8px',
|
padding: `6px ${rowHPadding} 6px 8px`,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
<BlocksuiteEditorJournalDocTitle page={page} />
|
<BlocksuiteEditorJournalDocTitle page={page} />
|
||||||
)}
|
)}
|
||||||
<PagePropertiesTable docId={page.id} />
|
{!shared ? <PagePropertiesTable docId={page.id} /> : null}
|
||||||
<adapted.DocEditor
|
<adapted.DocEditor
|
||||||
className={styles.docContainer}
|
className={styles.docContainer}
|
||||||
ref={onDocRef}
|
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 { Scrollable, useHasScrollTop } from '@affine/component';
|
||||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
|
||||||
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
||||||
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
|
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
|
||||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||||
import type { Editor } from '@affine/core/modules/editor';
|
import { EditorService } from '@affine/core/modules/editor';
|
||||||
import { EditorService, EditorsService } from '@affine/core/modules/editor';
|
|
||||||
import { RecentDocsService } from '@affine/core/modules/quicksearch';
|
import { RecentDocsService } from '@affine/core/modules/quicksearch';
|
||||||
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
||||||
import type { DocMode, PageRootService } from '@blocksuite/blocks';
|
import type { PageRootService } from '@blocksuite/blocks';
|
||||||
import {
|
import {
|
||||||
BookmarkBlockService,
|
BookmarkBlockService,
|
||||||
customImageProxyMiddleware,
|
customImageProxyMiddleware,
|
||||||
@@ -23,10 +21,8 @@ import { DisposableGroup } from '@blocksuite/global/utils';
|
|||||||
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
|
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||||
import { type AffineEditorContainer } from '@blocksuite/presets';
|
import { type AffineEditorContainer } from '@blocksuite/presets';
|
||||||
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
||||||
import type { Doc } from '@toeverything/infra';
|
|
||||||
import {
|
import {
|
||||||
DocService,
|
DocService,
|
||||||
DocsService,
|
|
||||||
FrameworkScope,
|
FrameworkScope,
|
||||||
globalBlockSuiteSchema,
|
globalBlockSuiteSchema,
|
||||||
GlobalContextService,
|
GlobalContextService,
|
||||||
@@ -36,15 +32,7 @@ import {
|
|||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { ReactElement } from 'react';
|
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import type { Map as YMap } from 'yjs';
|
import type { Map as YMap } from 'yjs';
|
||||||
|
|
||||||
@@ -65,9 +53,9 @@ import {
|
|||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
} from '../../../modules/workbench';
|
} from '../../../modules/workbench';
|
||||||
import { performanceRenderLogger } from '../../../shared';
|
import { performanceRenderLogger } from '../../../shared';
|
||||||
import { PageNotFound } from '../../404';
|
|
||||||
import * as styles from './detail-page.css';
|
import * as styles from './detail-page.css';
|
||||||
import { DetailPageHeader } from './detail-page-header';
|
import { DetailPageHeader } from './detail-page-header';
|
||||||
|
import { DetailPageWrapper } from './detail-page-wrapper';
|
||||||
import { EditorChatPanel } from './tabs/chat';
|
import { EditorChatPanel } from './tabs/chat';
|
||||||
import { EditorFramePanel } from './tabs/frame';
|
import { EditorFramePanel } from './tabs/frame';
|
||||||
import { EditorJournalPanel } from './tabs/journal';
|
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 = () => {
|
export const Component = () => {
|
||||||
performanceRenderLogger.debug('DetailPage');
|
performanceRenderLogger.debug('DetailPage');
|
||||||
|
|
||||||
@@ -448,5 +335,9 @@ export const Component = () => {
|
|||||||
|
|
||||||
const pageId = params.pageId;
|
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();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function preventDefault(event: BaseSyntheticEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
export function stopEvent(event: BaseSyntheticEvent) {
|
export function stopEvent(event: BaseSyntheticEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -45,5 +45,5 @@ export const prefix = style({
|
|||||||
export const suffix = style({
|
export const suffix = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -95,7 +95,7 @@ export const viewRoutes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/:pageId',
|
path: '/:pageId',
|
||||||
lazy: () => import('./pages/workspace/detail'),
|
lazy: () => import('./pages/workspace/detail/mobile-detail-page'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
|
|||||||
Reference in New Issue
Block a user