feat(core): add doc info modal (#7409)

close AF-1038
close AF-1039
close AF-1040
close AF-1046

A popup window has been added to facilitate viewing of this doc's info in edgeless mode and other modes.

https://github.com/toeverything/AFFiNE/assets/102217452/d7f94cb6-7e32-4ce7-8ff4-8aba1309b331
This commit is contained in:
JimmFly
2024-07-09 07:05:20 +00:00
parent aab9925aa1
commit e6818b4f14
27 changed files with 954 additions and 61 deletions

View File

@@ -24,6 +24,7 @@ export const runtimeFlagsSchema = z.object({
enablePayment: z.boolean(), enablePayment: z.boolean(),
enablePageHistory: z.boolean(), enablePageHistory: z.boolean(),
enableExperimentalFeature: z.boolean(), enableExperimentalFeature: z.boolean(),
enableInfoModal: z.boolean(),
allowLocalWorkspace: z.boolean(), allowLocalWorkspace: z.boolean(),
// this is for the electron app // this is for the electron app
serverUrlPrefix: z.string(), serverUrlPrefix: z.string(),

View File

@@ -13,6 +13,7 @@ export const openQuotaModalAtom = atom(false);
export const openStarAFFiNEModalAtom = atom(false); export const openStarAFFiNEModalAtom = atom(false);
export const openIssueFeedbackModalAtom = atom(false); export const openIssueFeedbackModalAtom = atom(false);
export const openHistoryTipsModalAtom = atom(false); export const openHistoryTipsModalAtom = atom(false);
export const openInfoModalAtom = atom(false);
export const rightSidebarWidthAtom = atom(320); export const rightSidebarWidthAtom = atom(320);

View File

@@ -1,3 +1,4 @@
export * from './icons-mapping'; export * from './icons-mapping';
export * from './info-modal/info-modal';
export * from './page-properties-manager'; export * from './page-properties-manager';
export * from './table'; export * from './table';

View File

@@ -0,0 +1,36 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const title = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
color: cssVar('textSecondaryColor'),
padding: '6px',
});
export const wrapper = style({
width: '100%',
borderRadius: 4,
color: cssVar('textPrimaryColor'),
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 2,
padding: '6px',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
globalStyle(`${wrapper} svg`, {
color: cssVar('iconSecondary'),
fontSize: 16,
transform: 'none',
});
globalStyle(`${wrapper} span`, {
fontSize: cssVar('fontSm'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
borderBottom: 'none',
});

View File

@@ -0,0 +1,33 @@
import { useI18n } from '@affine/i18n';
import { useContext } from 'react';
import { AffinePageReference } from '../../reference-link';
import { managerContext } from '../common';
import * as styles from './back-links-row.css';
export const BackLinksRow = ({
references,
onClick,
}: {
references: { docId: string; title: string }[];
onClick?: () => void;
}) => {
const manager = useContext(managerContext);
const t = useI18n();
return (
<div>
<div className={styles.title}>
{t['com.affine.page-properties.backlinks']()} · {references.length}
</div>
{references.map(link => (
<AffinePageReference
key={link.docId}
pageId={link.docId}
wrapper={props => (
<div className={styles.wrapper} onClick={onClick} {...props} />
)}
docCollection={manager.workspace.docCollection}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const container = style({
maxWidth: 480,
minWidth: 360,
padding: '20px 0',
alignSelf: 'start',
marginTop: '120px',
});
export const titleContainer = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
});
export const titleStyle = style({
fontSize: cssVar('fontH6'),
fontWeight: '600',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 6,
padding: 6,
width: '160px',
});
export const viewport = style({
maxHeight: 'calc(100vh - 220px)',
padding: '0 24px',
});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});

View File

@@ -0,0 +1,156 @@
import {
Divider,
type InlineEditHandle,
Modal,
Scrollable,
} from '@affine/component';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import type { Doc } from '@blocksuite/store';
import {
LiveData,
useLiveData,
useService,
type Workspace,
} from '@toeverything/infra';
import { Suspense, useCallback, useContext, useMemo, useRef } from 'react';
import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title';
import { managerContext } from '../common';
import {
PagePropertiesAddProperty,
PagePropertyRow,
SortableProperties,
usePagePropertiesManager,
} from '../table';
import { BackLinksRow } from './back-links-row';
import * as styles from './info-modal.css';
import { TagsRow } from './tags-row';
import { TimeRow } from './time-row';
export const InfoModal = ({
open,
onOpenChange,
page,
workspace,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
page: Doc;
workspace: Workspace;
}) => {
const titleInputHandleRef = useRef<InlineEditHandle>(null);
const manager = usePagePropertiesManager(page);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const docsSearchService = useService(DocsSearchService);
const references = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(page.id), null),
[docsSearchService, page.id]
)
);
if (!manager.page || manager.readonly) {
return null;
}
return (
<Modal
contentOptions={{
className: styles.container,
'aria-describedby': undefined,
}}
open={open}
onOpenChange={onOpenChange}
withoutCloseButton
>
<Scrollable.Root>
<Scrollable.Viewport
className={styles.viewport}
data-testid="info-modal"
>
<div className={styles.titleContainer} data-testid="info-modal-title">
<BlocksuiteHeaderTitle
className={styles.titleStyle}
inputHandleRef={titleInputHandleRef}
pageId={page.id}
docCollection={workspace.docCollection}
/>
</div>
<managerContext.Provider value={manager}>
<Suspense>
<InfoTable
docId={page.id}
onClose={handleClose}
references={references}
readonly={manager.readonly}
/>
</Suspense>
</managerContext.Provider>
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />
</Scrollable.Root>
</Modal>
);
};
const InfoTable = ({
onClose,
references,
docId,
readonly,
}: {
docId: string;
onClose: () => void;
readonly: boolean;
references:
| {
docId: string;
title: string;
}[]
| null;
}) => {
const manager = useContext(managerContext);
return (
<div>
<TimeRow docId={docId} />
<Divider size="thinner" />
{references && references.length > 0 ? (
<>
<BackLinksRow references={references} onClick={onClose} />
<Divider size="thinner" />
</>
) : null}
<TagsRow docId={docId} readonly={readonly} />
<SortableProperties>
{properties =>
properties.length ? (
<div>
{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}
/>
))}
</div>
) : null
}
</SortableProperties>
{manager.readonly ? null : <PagePropertiesAddProperty />}
</div>
);
};

View File

@@ -0,0 +1,102 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const icon = style({
fontSize: 16,
color: cssVar('iconSecondary'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
padding: 6,
width: '160px',
});
export const rowName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
});
export const time = style({
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
flexGrow: 1,
fontSize: cssVar('fontSm'),
});
export const rowCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'start',
gap: 4,
});
export const container = style({
display: 'flex',
flexDirection: 'column',
marginTop: 20,
marginBottom: 4,
});
export const rowValueCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
position: 'relative',
borderRadius: 4,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
userSelect: 'none',
':focus-visible': {
outline: 'none',
},
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
padding: '6px 8px',
border: `1px solid transparent`,
color: cssVar('textPrimaryColor'),
':focus': {
backgroundColor: cssVar('hoverColor'),
},
'::placeholder': {
color: cssVar('placeholderColor'),
},
selectors: {
'&[data-empty="true"]': {
color: cssVar('placeholderColor'),
},
'&[data-readonly=true]': {
pointerEvents: 'none',
},
},
flex: 1,
});
export const tagsMenu = style({
padding: 0,
transform:
'translate(-3.5px, calc(-3.5px + var(--radix-popper-anchor-height) * -1))',
width: 'calc(var(--radix-popper-anchor-width) + 16px)',
overflow: 'hidden',
});
export const tagsInlineEditor = style({
selectors: {
'&[data-empty=true]': {
color: cssVar('placeholderColor'),
},
},
});

View File

@@ -0,0 +1,58 @@
import { Menu } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { InlineTagsList, TagsEditor } from '../tags-inline-editor';
import * as styles from './tags-row.css';
export const TagsRow = ({
docId,
readonly,
}: {
docId: string;
readonly: boolean;
}) => {
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={styles.rowNameContainer}>
<div className={styles.icon}>
<TagsIcon />
</div>
<div className={styles.rowName}>{t['Tags']()}</div>
</div>
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={<TagsEditor pageId={docId} readonly={readonly} />}
>
<div
className={clsx(styles.tagsInlineEditor, styles.rowValueCell)}
data-empty={empty}
data-readonly={readonly}
data-testid="info-modal-tags-value"
>
{empty ? (
t['com.affine.page-properties.property-value-placeholder']()
) : (
<InlineTagsList pageId={docId} readonly />
)}
</div>
</Menu>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const icon = style({
fontSize: 16,
color: cssVar('iconSecondary'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
padding: 6,
width: '160px',
});
export const rowName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
});
export const time = style({
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
flexGrow: 1,
fontSize: cssVar('fontSm'),
});
export const rowCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
});
export const container = style({
display: 'flex',
flexDirection: 'column',
marginTop: 20,
marginBottom: 4,
});

View File

@@ -0,0 +1,92 @@
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import type { ConfigType } from 'dayjs';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { type ReactNode, useContext, useMemo } from 'react';
import { managerContext } from '../common';
import * as styles from './time-row.css';
const RowComponent = ({
name,
icon,
time,
}: {
name: string;
icon: ReactNode;
time?: string | null;
}) => {
return (
<div className={styles.rowCell}>
<div className={styles.rowNameContainer}>
<div className={styles.icon}>{icon}</div>
<span className={styles.rowName}>{name}</span>
</div>
<div className={styles.time}>{time ? time : 'unknown'}</div>
</div>
);
};
export const TimeRow = ({ docId }: { docId: string }) => {
const t = useI18n();
const manager = useContext(managerContext);
const workspaceService = useService(WorkspaceService);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docId)
);
const timestampElement = useMemo(() => {
const formatI18nTime = (time: ConfigType) =>
i18nTime(time, {
relative: {
max: [1, 'day'],
accuracy: 'minute',
},
absolute: {
accuracy: 'day',
},
});
const localizedCreateTime = manager.createDate
? formatI18nTime(manager.createDate)
: null;
return (
<>
<RowComponent
icon={<DateTimeIcon />}
name={t['Created']()}
time={
manager.createDate
? formatI18nTime(manager.createDate)
: localizedCreateTime
}
/>
{serverClock ? (
<RowComponent
icon={<HistoryIcon />}
name={t[!syncing && !retrying ? 'Updated' : 'com.affine.syncing']()}
time={!syncing && !retrying ? formatI18nTime(serverClock) : null}
/>
) : manager.updatedDate ? (
<RowComponent
icon={<HistoryIcon />}
name={t['Updated']()}
time={formatI18nTime(manager.updatedDate)}
/>
) : null}
</>
);
}, [
manager.createDate,
manager.updatedDate,
retrying,
serverClock,
syncing,
t,
]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);
return <div className={styles.container}>{dTimestampElement}</div>;
};

View File

@@ -129,6 +129,16 @@ export const addPropertyButton = style({
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
backgroundColor: cssVar('hoverColor'), backgroundColor: cssVar('hoverColor'),
}, },
gap: 2,
fontWeight: 400,
});
globalStyle(`${addPropertyButton} svg`, {
fontSize: 16,
color: cssVar('iconSecondary'),
});
globalStyle(`${addPropertyButton}:hover svg`, {
color: cssVar('iconColor'),
}); });
export const collapsedIcon = style({ export const collapsedIcon = style({
@@ -262,7 +272,7 @@ export const propertyRowIconContainer = style({
justifyContent: 'center', justifyContent: 'center',
borderRadius: '2px', borderRadius: '2px',
fontSize: 16, fontSize: 16,
color: 'inherit', color: cssVar('iconSecondary'),
}); });
export const propertyRowNameContainer = style({ export const propertyRowNameContainer = style({

View File

@@ -105,7 +105,7 @@ interface SortablePropertiesProps {
children: (properties: PageInfoCustomProperty[]) => React.ReactNode; children: (properties: PageInfoCustomProperty[]) => React.ReactNode;
} }
const SortableProperties = ({ children }: SortablePropertiesProps) => { export const SortableProperties = ({ children }: SortablePropertiesProps) => {
const manager = useContext(managerContext); const manager = useContext(managerContext);
const properties = useMemo(() => manager.sorter.getOrderedItems(), [manager]); const properties = useMemo(() => manager.sorter.getOrderedItems(), [manager]);
const editingItem = useAtomValue(editingPropertyAtom); const editingItem = useAtomValue(editingPropertyAtom);
@@ -735,9 +735,13 @@ export const PagePropertiesTableHeader = ({
interface PagePropertyRowProps { interface PagePropertyRowProps {
property: PageInfoCustomProperty; property: PageInfoCustomProperty;
style?: React.CSSProperties; style?: React.CSSProperties;
rowNameClassName?: string;
} }
const PagePropertyRow = ({ property }: PagePropertyRowProps) => { export const PagePropertyRow = ({
property,
rowNameClassName,
}: PagePropertyRowProps) => {
const manager = useContext(managerContext); const manager = useContext(managerContext);
const meta = manager.getCustomPropertyMeta(property.id); const meta = manager.getCustomPropertyMeta(property.id);
@@ -772,7 +776,10 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
{...attributes} {...attributes}
{...listeners} {...listeners}
data-testid="page-property-row-name" data-testid="page-property-row-name"
className={styles.sortablePropertyRowNameCell} className={clsx(
styles.sortablePropertyRowNameCell,
rowNameClassName
)}
onClick={handleEditMeta} onClick={handleEditMeta}
> >
<div className={styles.propertyRowNameContainer}> <div className={styles.propertyRowNameContainer}>
@@ -790,7 +797,11 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
); );
}; };
const PageTagsRow = () => { export const PageTagsRow = ({
rowNameClassName,
}: {
rowNameClassName?: string;
}) => {
const t = useI18n(); const t = useI18n();
return ( return (
<div <div
@@ -799,7 +810,7 @@ const PageTagsRow = () => {
data-property="tags" data-property="tags"
> >
<div <div
className={styles.propertyRowNameCell} className={clsx(styles.propertyRowNameCell, rowNameClassName)}
data-testid="page-property-row-name" data-testid="page-property-row-name"
> >
<div className={styles.propertyRowNameContainer}> <div className={styles.propertyRowNameContainer}>
@@ -1074,7 +1085,7 @@ const PagePropertiesTableInner = () => {
); );
}; };
const usePagePropertiesManager = (page: Doc) => { export const usePagePropertiesManager = (page: Doc) => {
// the workspace properties adapter adapter is reactive, // the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change // which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component // also it will trigger a re-render of the component

View File

@@ -30,7 +30,7 @@ interface InlineTagsListProps
onRemove?: () => void; onRemove?: () => void;
} }
const InlineTagsList = ({ export const InlineTagsList = ({
pageId, pageId,
readonly, readonly,
children, children,

View File

@@ -0,0 +1,22 @@
import { IconButton, Tooltip } from '@affine/component';
import { openInfoModalAtom } from '@affine/core/atoms';
import { useI18n } from '@affine/i18n';
import { InformationIcon } from '@blocksuite/icons/rc';
import { useSetAtom } from 'jotai';
export const InfoButton = () => {
const setOpenInfoModal = useSetAtom(openInfoModalAtom);
const t = useI18n();
const onOpenInfoModal = () => {
setOpenInfoModal(true);
};
return (
<Tooltip content={t['com.affine.page-properties.page-info.view']()}>
<IconButton
data-testid="header-info-button"
onClick={onOpenInfoModal}
icon={<InformationIcon />}
/>
</Tooltip>
);
};

View File

@@ -6,7 +6,10 @@ import {
MenuSeparator, MenuSeparator,
MenuSub, MenuSub,
} from '@affine/component/ui/menu'; } from '@affine/component/ui/menu';
import { openHistoryTipsModalAtom } from '@affine/core/atoms'; import {
openHistoryTipsModalAtom,
openInfoModalAtom,
} from '@affine/core/atoms';
import { PageHistoryModal } from '@affine/core/components/affine/page-history-modal'; import { PageHistoryModal } from '@affine/core/components/affine/page-history-modal';
import { ShareMenuContent } from '@affine/core/components/affine/share-page-modal/share-menu'; import { ShareMenuContent } from '@affine/core/components/affine/share-page-modal/share-menu';
import { Export, MoveToTrash } from '@affine/core/components/page-list'; import { Export, MoveToTrash } from '@affine/core/components/page-list';
@@ -27,6 +30,7 @@ import {
FavoriteIcon, FavoriteIcon,
HistoryIcon, HistoryIcon,
ImportIcon, ImportIcon,
InformationIcon,
PageIcon, PageIcon,
ShareIcon, ShareIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
@@ -83,6 +87,11 @@ export const PageHeaderMenuButton = ({
return setOpenHistoryTipsModal(true); return setOpenHistoryTipsModal(true);
}, [setOpenHistoryTipsModal, workspace.flavour]); }, [setOpenHistoryTipsModal, workspace.flavour]);
const setOpenInfoModal = useSetAtom(openInfoModalAtom);
const openInfoModal = () => {
setOpenInfoModal(true);
};
const handleOpenTrashModal = useCallback(() => { const handleOpenTrashModal = useCallback(() => {
setTrashModal({ setTrashModal({
open: true, open: true,
@@ -236,6 +245,35 @@ export const PageHeaderMenuButton = ({
{t['com.affine.header.option.add-tag']()} {t['com.affine.header.option.add-tag']()}
</MenuItem> */} </MenuItem> */}
<MenuSeparator /> <MenuSeparator />
{runtimeConfig.enableInfoModal ? (
<MenuItem
preFix={
<MenuIcon>
<InformationIcon />
</MenuIcon>
}
data-testid="editor-option-menu-info"
onSelect={openInfoModal}
style={menuItemStyle}
>
{t['com.affine.page-properties.page-info.view']()}
</MenuItem>
) : null}
{runtimeConfig.enablePageHistory ? (
<MenuItem
preFix={
<MenuIcon>
<HistoryIcon />
</MenuIcon>
}
data-testid="editor-option-menu-history"
onSelect={openHistoryModal}
style={menuItemStyle}
>
{t['com.affine.history.view-history-version']()}
</MenuItem>
) : null}
<MenuSeparator />
{!isJournal && ( {!isJournal && (
<MenuItem <MenuItem
preFix={ preFix={
@@ -264,21 +302,6 @@ export const PageHeaderMenuButton = ({
</MenuItem> </MenuItem>
<Export exportHandler={exportHandler} pageMode={currentMode} /> <Export exportHandler={exportHandler} pageMode={currentMode} />
{runtimeConfig.enablePageHistory ? (
<MenuItem
preFix={
<MenuIcon>
<HistoryIcon />
</MenuIcon>
}
data-testid="editor-option-menu-history"
onSelect={openHistoryModal}
style={menuItemStyle}
>
{t['com.affine.history.view-history-version']()}
</MenuItem>
) : null}
<MenuSeparator /> <MenuSeparator />
<MoveToTrash <MoveToTrash
data-testid="editor-option-menu-delete" data-testid="editor-option-menu-delete"

View File

@@ -5,6 +5,7 @@ import {
useDocMetaHelper, useDocMetaHelper,
} from '@affine/core/hooks/use-block-suite-page-meta'; } from '@affine/core/hooks/use-block-suite-page-meta';
import type { DocCollection } from '@affine/core/shared'; import type { DocCollection } from '@affine/core/shared';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -16,6 +17,7 @@ export interface BlockSuiteHeaderTitleProps {
/** if set, title cannot be edited */ /** if set, title cannot be edited */
isPublic?: boolean; isPublic?: boolean;
inputHandleRef?: InlineEditProps['handleRef']; inputHandleRef?: InlineEditProps['handleRef'];
className?: string;
} }
const inputAttrs = { const inputAttrs = {
@@ -39,7 +41,7 @@ export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
return ( return (
<InlineEdit <InlineEdit
className={styles.title} className={clsx(styles.title, props.className)}
autoSelect autoSelect
value={title} value={title}
onChange={onChange} onChange={onChange}

View File

@@ -25,6 +25,7 @@ import {
FavoriteIcon, FavoriteIcon,
FilterIcon, FilterIcon,
FilterMinusIcon, FilterMinusIcon,
InformationIcon,
MoreVerticalIcon, MoreVerticalIcon,
PlusIcon, PlusIcon,
ResetIcon, ResetIcon,
@@ -36,6 +37,7 @@ import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { CollectionService } from '../../modules/collection'; import type { CollectionService } from '../../modules/collection';
import { InfoModal } from '../affine/page-properties';
import { usePageHelper } from '../blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
import { FavoriteTag } from './components/favorite-tag'; import { FavoriteTag } from './components/favorite-tag';
import * as styles from './list.css'; import * as styles from './list.css';
@@ -65,6 +67,12 @@ export const PageOperationCell = ({
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc')); const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection); const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection);
const blocksuiteDoc = currentWorkspace.docCollection.getDoc(page.id);
const [openInfoModal, setOpenInfoModal] = useState(false);
const onOpenInfoModal = () => {
setOpenInfoModal(true);
};
const onDisablePublicSharing = useCallback(() => { const onDisablePublicSharing = useCallback(() => {
toast('Successfully disabled', { toast('Successfully disabled', {
@@ -144,6 +152,18 @@ export const PageOperationCell = ({
? t['com.affine.favoritePageOperation.remove']() ? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()} : t['com.affine.favoritePageOperation.add']()}
</MenuItem> </MenuItem>
{runtimeConfig.enableInfoModal ? (
<MenuItem
onClick={onOpenInfoModal}
preFix={
<MenuIcon>
<InformationIcon />
</MenuIcon>
}
>
{t['com.affine.page-properties.page-info.view']()}
</MenuItem>
) : null}
{environment.isDesktop && appSettings.enableMultiView ? ( {environment.isDesktop && appSettings.enableMultiView ? (
<MenuItem <MenuItem
@@ -215,6 +235,14 @@ export const PageOperationCell = ({
</IconButton> </IconButton>
</Menu> </Menu>
</ColWrapper> </ColWrapper>
{blocksuiteDoc ? (
<InfoModal
open={openInfoModal}
onOpenChange={setOpenInfoModal}
page={blocksuiteDoc}
workspace={currentWorkspace}
/>
) : null}
<DisablePublicSharing.DisablePublicSharingModal <DisablePublicSharing.DisablePublicSharingModal
onConfirm={onDisablePublicSharing} onConfirm={onDisablePublicSharing}
open={openDisableShared} open={openDisableShared}

View File

@@ -7,6 +7,7 @@ import {
EditIcon, EditIcon,
FavoriteIcon, FavoriteIcon,
FilterMinusIcon, FilterMinusIcon,
InformationIcon,
LinkedPageIcon, LinkedPageIcon,
SplitViewIcon, SplitViewIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
@@ -24,6 +25,7 @@ type OperationItemsProps = {
onRemoveFromFavourites?: () => void; onRemoveFromFavourites?: () => void;
onDelete: () => void; onDelete: () => void;
onOpenInSplitView: () => void; onOpenInSplitView: () => void;
onOpenInfoModal: () => void;
}; };
export const OperationItems = ({ export const OperationItems = ({
@@ -36,6 +38,7 @@ export const OperationItems = ({
onRemoveFromFavourites, onRemoveFromFavourites,
onDelete, onDelete,
onOpenInSplitView, onOpenInSplitView,
onOpenInfoModal,
}: OperationItemsProps) => { }: OperationItemsProps) => {
const { appSettings } = useAppSettingHelper(); const { appSettings } = useAppSettingHelper();
const t = useI18n(); const t = useI18n();
@@ -63,6 +66,19 @@ export const OperationItems = ({
name: t['Rename'](), name: t['Rename'](),
click: onRename, click: onRename,
}, },
...(runtimeConfig.enableInfoModal
? [
{
icon: (
<MenuIcon>
<InformationIcon />
</MenuIcon>
),
name: t['com.affine.page-properties.page-info.view'](),
click: onOpenInfoModal,
},
]
: []),
{ {
icon: ( icon: (
<MenuIcon> <MenuIcon>
@@ -123,7 +139,7 @@ export const OperationItems = ({
<DeleteIcon /> <DeleteIcon />
</MenuIcon> </MenuIcon>
), ),
name: t['com.affine.trashOperation.delete'](), name: t['com.affine.moveToTrash.title'](),
click: onDelete, click: onDelete,
type: 'danger', type: 'danger',
}, },
@@ -139,6 +155,7 @@ export const OperationItems = ({
onRemoveFromAllowList, onRemoveFromAllowList,
appSettings.enableMultiView, appSettings.enableMultiView,
onOpenInSplitView, onOpenInSplitView,
onOpenInfoModal,
onDelete, onDelete,
] ]
); );

View File

@@ -1,12 +1,13 @@
import { toast } from '@affine/component'; import { toast } from '@affine/component';
import { IconButton } from '@affine/component/ui/button'; import { IconButton } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu'; import { Menu } from '@affine/component/ui/menu';
import { InfoModal } from '@affine/core/components/affine/page-properties';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useService, useServices, WorkspaceService } from '@toeverything/infra'; import { useService, useServices, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper'; import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
@@ -33,9 +34,12 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
isReferencePage, isReferencePage,
} = props; } = props;
const t = useI18n(); const t = useI18n();
const [openInfoModal, setOpenInfoModal] = useState(false);
const { workspaceService } = useServices({ const { workspaceService } = useServices({
WorkspaceService, WorkspaceService,
}); });
const page = workspaceService.workspace.docCollection.getDoc(pageId);
const { createLinkedPage } = usePageHelper( const { createLinkedPage } = usePageHelper(
workspaceService.workspace.docCollection workspaceService.workspace.docCollection
); );
@@ -76,30 +80,45 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
workbench.openDoc(pageId, { at: 'tail' }); workbench.openDoc(pageId, { at: 'tail' });
}, [pageId, workbench]); }, [pageId, workbench]);
const handleOpenInfoModal = useCallback(() => {
setOpenInfoModal(true);
}, [setOpenInfoModal]);
return ( return (
<Menu <>
items={ <Menu
<OperationItems items={
onAddLinkedPage={handleAddLinkedPage} <OperationItems
onDelete={handleDelete} onAddLinkedPage={handleAddLinkedPage}
onRemoveFromAllowList={handleRemoveFromAllowList} onDelete={handleDelete}
onRemoveFromFavourites={handleRemoveFromFavourites} onRemoveFromAllowList={handleRemoveFromAllowList}
onRename={handleRename} onRemoveFromFavourites={handleRemoveFromFavourites}
onOpenInSplitView={handleOpenInSplitView} onRename={handleRename}
inAllowList={inAllowList} onOpenInSplitView={handleOpenInSplitView}
inFavorites={inFavorites} onOpenInfoModal={handleOpenInfoModal}
isReferencePage={isReferencePage} inAllowList={inAllowList}
/> inFavorites={inFavorites}
} isReferencePage={isReferencePage}
> />
<IconButton }
size="small"
type="plain"
data-testid="left-sidebar-page-operation-button"
style={{ marginLeft: 4 }}
> >
<MoreHorizontalIcon /> <IconButton
</IconButton> size="small"
</Menu> type="plain"
data-testid="left-sidebar-page-operation-button"
style={{ marginLeft: 4 }}
>
<MoreHorizontalIcon />
</IconButton>
</Menu>
{page ? (
<InfoModal
open={openInfoModal}
onOpenChange={setOpenInfoModal}
page={page}
workspace={workspaceService.workspace}
/>
) : null}
</>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { toast } from '@affine/component'; import { toast } from '@affine/component';
import { openInfoModalAtom } from '@affine/core/atoms';
import { import {
PreconditionStrategy, PreconditionStrategy,
registerAffineCommand, registerAffineCommand,
@@ -36,6 +37,7 @@ export function useRegisterBlocksuiteEditorCommands() {
const trash = useLiveData(doc.trash$); const trash = useLiveData(doc.trash$);
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom); const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
const setInfoModalState = useSetAtom(openInfoModalAtom);
const openHistoryModal = useCallback(() => { const openHistoryModal = useCallback(() => {
setPageHistoryModalState(() => ({ setPageHistoryModalState(() => ({
@@ -44,8 +46,11 @@ export function useRegisterBlocksuiteEditorCommands() {
})); }));
}, [docId, setPageHistoryModalState]); }, [docId, setPageHistoryModalState]);
const { restoreFromTrash, duplicate } = const openInfoModal = useCallback(() => {
useBlockSuiteMetaHelper(docCollection); setInfoModalState(true);
}, [setInfoModalState]);
const { duplicate } = useBlockSuiteMetaHelper(docCollection);
const exportHandler = useExportPage(doc.blockSuiteDoc); const exportHandler = useExportPage(doc.blockSuiteDoc);
const { setTrashModal } = useTrashModalHelper(docCollection); const { setTrashModal } = useTrashModalHelper(docCollection);
const onClickDelete = useCallback( const onClickDelete = useCallback(
@@ -89,6 +94,22 @@ export function useRegisterBlocksuiteEditorCommands() {
// }) // })
// ); // );
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-view-info`,
preconditionStrategy: () =>
PreconditionStrategy.InPaperOrEdgeless &&
!trash &&
runtimeConfig.enableInfoModal,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.page-properties.page-info.view'](),
run() {
openInfoModal();
},
})
);
unsubs.push( unsubs.push(
registerAffineCommand({ registerAffineCommand({
id: `editor:${mode}-${favorite ? 'remove-from' : 'add-to'}-favourites`, id: `editor:${mode}-${favorite ? 'remove-from' : 'add-to'}-favourites`,
@@ -270,7 +291,6 @@ export function useRegisterBlocksuiteEditorCommands() {
mode, mode,
onClickDelete, onClickDelete,
exportHandler, exportHandler,
restoreFromTrash,
t, t,
trash, trash,
isCloudWorkspace, isCloudWorkspace,
@@ -280,5 +300,6 @@ export function useRegisterBlocksuiteEditorCommands() {
docId, docId,
doc, doc,
telemetry, telemetry,
openInfoModal,
]); ]);
} }

View File

@@ -18,3 +18,9 @@ export const journalWeekPicker = style({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}); });
export const iconButtonContainer = style({
display: 'flex',
alignItems: 'center',
gap: 10,
});

View File

@@ -1,5 +1,8 @@
import { Divider, type InlineEditHandle } from '@affine/component'; import { Divider, type InlineEditHandle } from '@affine/component';
import { openInfoModalAtom } from '@affine/core/atoms';
import { InfoModal } from '@affine/core/components/affine/page-properties';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite'; import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { InfoButton } from '@affine/core/components/blocksuite/block-suite-header/info';
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker'; import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button'; import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button';
import { PageHeaderMenuButton } from '@affine/core/components/blocksuite/block-suite-header/menu'; import { PageHeaderMenuButton } from '@affine/core/components/blocksuite/block-suite-header/menu';
@@ -9,7 +12,7 @@ import { useRegisterCopyLinkCommands } from '@affine/core/hooks/affine/use-regis
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import type { Doc } from '@blocksuite/store'; import type { Doc } from '@blocksuite/store';
import { type Workspace } from '@toeverything/infra'; import { type Workspace } from '@toeverything/infra';
import { useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { SharePageButton } from '../../../components/affine/share-page-modal'; import { SharePageButton } from '../../../components/affine/share-page-modal';
@@ -90,8 +93,16 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
pageId={page?.id} pageId={page?.id}
docCollection={workspace.docCollection} docCollection={workspace.docCollection}
/> />
{hideCollect ? null : <FavoriteButton pageId={page?.id} />} <div className={styles.iconButtonContainer}>
<PageHeaderMenuButton rename={onRename} page={page} /> {hideCollect ? null : (
<>
<FavoriteButton pageId={page?.id} />
{runtimeConfig.enableInfoModal ? <InfoButton /> : null}
</>
)}
<PageHeaderMenuButton rename={onRename} page={page} />
</div>
<div className={styles.spacer} /> <div className={styles.spacer} />
{!hidePresent ? <DetailPageHeaderPresentButton /> : null} {!hidePresent ? <DetailPageHeaderPresentButton /> : null}
@@ -111,15 +122,26 @@ export function DetailPageHeader(props: PageHeaderProps) {
const { page, workspace } = props; const { page, workspace } = props;
const { isJournal } = useJournalInfoHelper(page.collection, page.id); const { isJournal } = useJournalInfoHelper(page.collection, page.id);
const isInTrash = page.meta?.trash; const isInTrash = page.meta?.trash;
const [openInfoModal, setOpenInfoModal] = useAtom(openInfoModalAtom);
useRegisterCopyLinkCommands({ useRegisterCopyLinkCommands({
workspaceMeta: workspace.meta, workspaceMeta: workspace.meta,
docId: page.id, docId: page.id,
}); });
return isJournal && !isInTrash ? ( return (
<JournalPageHeader {...props} /> <>
) : ( {isJournal && !isInTrash ? (
<NormalPageHeader {...props} /> <JournalPageHeader {...props} />
) : (
<NormalPageHeader {...props} />
)}
<InfoModal
open={openInfoModal}
onOpenChange={setOpenInfoModal}
page={page}
workspace={workspace}
/>
</>
); );
} }

View File

@@ -846,6 +846,7 @@
"com.affine.page-properties.create-property.menu.header": "Type", "com.affine.page-properties.create-property.menu.header": "Type",
"com.affine.page-properties.icons": "Icons", "com.affine.page-properties.icons": "Icons",
"com.affine.page-properties.page-info": "Info", "com.affine.page-properties.page-info": "Info",
"com.affine.page-properties.page-info.view": "View Info",
"com.affine.page-properties.property-value-placeholder": "Empty", "com.affine.page-properties.property-value-placeholder": "Empty",
"com.affine.page-properties.property.always-hide": "Always hide", "com.affine.page-properties.property.always-hide": "Always hide",
"com.affine.page-properties.property.always-show": "Always show", "com.affine.page-properties.property.always-show": "Always show",

View File

@@ -0,0 +1,139 @@
import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
clickNewPageButton,
clickPageMoreActions,
getBlockSuiteEditorTitle,
getPageByTitle,
getPageOperationButton,
waitForEmptyEditor,
} from '@affine-test/kit/utils/page-logic';
import {
addCustomProperty,
closeTagsEditor,
ensurePagePropertiesVisible,
expectTagsVisible,
filterTags,
removeSelectedTag,
} from '@affine-test/kit/utils/properties';
import { expect, type Page } from '@playwright/test';
const searchAndCreateTag = async (page: Page, name: string) => {
await filterTags(page, name);
await page
.locator(
'[data-testid="tags-editor-popup"] [data-testid="tag-selector-item"]:has-text("Create ")'
)
.click();
};
test.beforeEach(async ({ page }) => {
await openHomePage(page);
await clickNewPageButton(page);
await waitForEmptyEditor(page);
await ensurePagePropertiesVisible(page);
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page');
});
test('New a page and open it ,then open info modal in the title bar', async ({
page,
}) => {
await page.getByTestId('header-info-button').click();
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
test('New a page and open it ,then open info modal in the title bar more action button', async ({
page,
}) => {
await clickPageMoreActions(page);
await page.getByTestId('editor-option-menu-info').click();
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
test('New a page, then open info modal from all doc', async ({ page }) => {
const newPageId = page.url().split('/').reverse()[0];
await page.getByTestId('all-pages').click();
const cell = getPageByTitle(page, 'this is a new page');
expect(cell).not.toBeUndefined();
await getPageOperationButton(page, newPageId).click();
await page.getByRole('menuitem', { name: 'View Info' }).click();
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
test('New a page and add to favourites, then open info modal from sidebar', async ({
page,
}) => {
const newPageId = page.url().split('/').reverse()[0];
await clickPageMoreActions(page);
await page.getByTestId('editor-option-menu-favorite').click();
await page.getByTestId('all-pages').click();
const favoriteListItemInSidebar = page.getByTestId(
'favourite-page-' + newPageId
);
expect(await favoriteListItemInSidebar.textContent()).toBe(
'this is a new page'
);
await favoriteListItemInSidebar.hover();
await favoriteListItemInSidebar
.getByTestId('left-sidebar-page-operation-button')
.click();
const infoBtn = page.getByText('View Info');
await infoBtn.click();
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
test('allow create tag', async ({ page }) => {
await page.getByTestId('header-info-button').click();
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
await page.getByTestId('info-modal-tags-value').click();
await searchAndCreateTag(page, 'Test1');
await searchAndCreateTag(page, 'Test2');
await closeTagsEditor(page);
await expectTagsVisible(page, ['Test1', 'Test2']);
await page.getByTestId('info-modal-tags-value').click();
await removeSelectedTag(page, 'Test1');
await closeTagsEditor(page);
await expectTagsVisible(page, ['Test2']);
});
test('add custom property', async ({ page }) => {
await page.getByTestId('header-info-button').click();
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
await addCustomProperty(page, 'Text');
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
});

View File

@@ -76,7 +76,7 @@ test('Show collections items in sidebar', async ({ page }) => {
await collectionPage await collectionPage
.getByTestId('left-sidebar-page-operation-button') .getByTestId('left-sidebar-page-operation-button')
.click(); .click();
const deletePage = page.getByText('Delete'); const deletePage = page.getByText('Move to Trash');
await deletePage.click(); await deletePage.click();
await page.getByTestId('confirm-delete-page').click(); await page.getByTestId('confirm-delete-page').click();
expect(await collections.getByTestId('collection-page').count()).toBe(0); expect(await collections.getByTestId('collection-page').count()).toBe(0);

View File

@@ -23,6 +23,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enablePayment: true, enablePayment: true,
enablePageHistory: true, enablePageHistory: true,
enableExperimentalFeature: false, enableExperimentalFeature: false,
enableInfoModal: false,
allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false, allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false,
serverUrlPrefix: 'https://app.affine.pro', serverUrlPrefix: 'https://app.affine.pro',
appVersion: packageJson.version, appVersion: packageJson.version,
@@ -63,6 +64,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enablePayment: true, enablePayment: true,
enablePageHistory: true, enablePageHistory: true,
enableExperimentalFeature: true, enableExperimentalFeature: true,
enableInfoModal: true,
allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false, allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false,
serverUrlPrefix: 'https://affine.fail', serverUrlPrefix: 'https://affine.fail',
appVersion: packageJson.version, appVersion: packageJson.version,