From e6818b4f1470abe533d3e034ad4cb93e7587c11b Mon Sep 17 00:00:00 2001
From: JimmFly <447268514@qq.com>
Date: Tue, 9 Jul 2024 07:05:20 +0000
Subject: [PATCH] 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
---
packages/common/env/src/global.ts | 1 +
packages/frontend/core/src/atoms/index.ts | 1 +
.../affine/page-properties/index.ts | 1 +
.../info-modal/back-links-row.css.ts | 36 ++++
.../info-modal/back-links-row.tsx | 33 ++++
.../info-modal/info-modal.css.ts | 39 +++++
.../page-properties/info-modal/info-modal.tsx | 156 ++++++++++++++++++
.../info-modal/tags-row.css.ts | 102 ++++++++++++
.../page-properties/info-modal/tags-row.tsx | 58 +++++++
.../info-modal/time-row.css.ts | 51 ++++++
.../page-properties/info-modal/time-row.tsx | 92 +++++++++++
.../affine/page-properties/styles.css.ts | 12 +-
.../affine/page-properties/table.tsx | 23 ++-
.../page-properties/tags-inline-editor.tsx | 2 +-
.../block-suite-header/info/index.tsx | 22 +++
.../block-suite-header/menu/index.tsx | 55 ++++--
.../block-suite-header/title/index.tsx | 4 +-
.../components/page-list/operation-cell.tsx | 28 ++++
.../components/operation-item.tsx | 19 ++-
.../components/operation-menu-button.tsx | 67 +++++---
...se-register-blocksuite-editor-commands.tsx | 27 ++-
.../detail-page/detail-page-header.css.ts | 6 +
.../detail-page/detail-page-header.tsx | 36 +++-
packages/frontend/i18n/src/resources/en.json | 1 +
tests/affine-local/e2e/doc-info-modal.spec.ts | 139 ++++++++++++++++
.../e2e/local-first-collections-items.spec.ts | 2 +-
tools/cli/src/webpack/runtime-config.ts | 2 +
27 files changed, 954 insertions(+), 61 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.css.ts
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.tsx
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts
create mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx
create mode 100644 packages/frontend/core/src/components/blocksuite/block-suite-header/info/index.tsx
create mode 100644 tests/affine-local/e2e/doc-info-modal.spec.ts
diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts
index 279ec822e2..a60292c132 100644
--- a/packages/common/env/src/global.ts
+++ b/packages/common/env/src/global.ts
@@ -24,6 +24,7 @@ export const runtimeFlagsSchema = z.object({
enablePayment: z.boolean(),
enablePageHistory: z.boolean(),
enableExperimentalFeature: z.boolean(),
+ enableInfoModal: z.boolean(),
allowLocalWorkspace: z.boolean(),
// this is for the electron app
serverUrlPrefix: z.string(),
diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts
index 55f3b132eb..4652a880c2 100644
--- a/packages/frontend/core/src/atoms/index.ts
+++ b/packages/frontend/core/src/atoms/index.ts
@@ -13,6 +13,7 @@ export const openQuotaModalAtom = atom(false);
export const openStarAFFiNEModalAtom = atom(false);
export const openIssueFeedbackModalAtom = atom(false);
export const openHistoryTipsModalAtom = atom(false);
+export const openInfoModalAtom = atom(false);
export const rightSidebarWidthAtom = atom(320);
diff --git a/packages/frontend/core/src/components/affine/page-properties/index.ts b/packages/frontend/core/src/components/affine/page-properties/index.ts
index f9630e4355..49e6ad80cf 100644
--- a/packages/frontend/core/src/components/affine/page-properties/index.ts
+++ b/packages/frontend/core/src/components/affine/page-properties/index.ts
@@ -1,3 +1,4 @@
export * from './icons-mapping';
+export * from './info-modal/info-modal';
export * from './page-properties-manager';
export * from './table';
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.css.ts
new file mode 100644
index 0000000000..e7ae8f64e7
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.css.ts
@@ -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',
+});
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.tsx
new file mode 100644
index 0000000000..e32cdadac5
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/back-links-row.tsx
@@ -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 (
+
+
+ {t['com.affine.page-properties.backlinks']()} ยท {references.length}
+
+ {references.map(link => (
+
(
+
+ )}
+ docCollection={manager.workspace.docCollection}
+ />
+ ))}
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts
new file mode 100644
index 0000000000..6a4914619b
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts
@@ -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)',
+});
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx
new file mode 100644
index 0000000000..1c4e10fade
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const InfoTable = ({
+ onClose,
+ references,
+ docId,
+ readonly,
+}: {
+ docId: string;
+ onClose: () => void;
+ readonly: boolean;
+ references:
+ | {
+ docId: string;
+ title: string;
+ }[]
+ | null;
+}) => {
+ const manager = useContext(managerContext);
+
+ return (
+
+
+
+ {references && references.length > 0 ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+ {properties =>
+ properties.length ? (
+
+ {properties
+ .filter(
+ property =>
+ manager.isPropertyRequired(property.id) ||
+ (property.visibility !== 'hide' &&
+ !(
+ property.visibility === 'hide-if-empty' &&
+ !property.value
+ ))
+ )
+ .map(property => (
+
+ ))}
+
+ ) : null
+ }
+
+ {manager.readonly ? null :
}
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts
new file mode 100644
index 0000000000..89a27aa750
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts
@@ -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'),
+ },
+ },
+});
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx
new file mode 100644
index 0000000000..bd6da44eba
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx
@@ -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 (
+
+
+
}
+ >
+
+ {empty ? (
+ t['com.affine.page-properties.property-value-placeholder']()
+ ) : (
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts
new file mode 100644
index 0000000000..a2c591691c
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts
@@ -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,
+});
diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx
new file mode 100644
index 0000000000..c099d0ad28
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx
@@ -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 (
+
+
+
{time ? time : 'unknown'}
+
+ );
+};
+
+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 (
+ <>
+ }
+ name={t['Created']()}
+ time={
+ manager.createDate
+ ? formatI18nTime(manager.createDate)
+ : localizedCreateTime
+ }
+ />
+ {serverClock ? (
+ }
+ name={t[!syncing && !retrying ? 'Updated' : 'com.affine.syncing']()}
+ time={!syncing && !retrying ? formatI18nTime(serverClock) : null}
+ />
+ ) : manager.updatedDate ? (
+ }
+ name={t['Updated']()}
+ time={formatI18nTime(manager.updatedDate)}
+ />
+ ) : null}
+ >
+ );
+ }, [
+ manager.createDate,
+ manager.updatedDate,
+ retrying,
+ serverClock,
+ syncing,
+ t,
+ ]);
+
+ const dTimestampElement = useDebouncedValue(timestampElement, 500);
+
+ return {dTimestampElement}
;
+};
diff --git a/packages/frontend/core/src/components/affine/page-properties/styles.css.ts b/packages/frontend/core/src/components/affine/page-properties/styles.css.ts
index 8c473eafcc..4d8f1e08e1 100644
--- a/packages/frontend/core/src/components/affine/page-properties/styles.css.ts
+++ b/packages/frontend/core/src/components/affine/page-properties/styles.css.ts
@@ -129,6 +129,16 @@ export const addPropertyButton = style({
color: cssVar('textPrimaryColor'),
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({
@@ -262,7 +272,7 @@ export const propertyRowIconContainer = style({
justifyContent: 'center',
borderRadius: '2px',
fontSize: 16,
- color: 'inherit',
+ color: cssVar('iconSecondary'),
});
export const propertyRowNameContainer = style({
diff --git a/packages/frontend/core/src/components/affine/page-properties/table.tsx b/packages/frontend/core/src/components/affine/page-properties/table.tsx
index 59ce753ec8..8d06414035 100644
--- a/packages/frontend/core/src/components/affine/page-properties/table.tsx
+++ b/packages/frontend/core/src/components/affine/page-properties/table.tsx
@@ -105,7 +105,7 @@ interface SortablePropertiesProps {
children: (properties: PageInfoCustomProperty[]) => React.ReactNode;
}
-const SortableProperties = ({ children }: SortablePropertiesProps) => {
+export const SortableProperties = ({ children }: SortablePropertiesProps) => {
const manager = useContext(managerContext);
const properties = useMemo(() => manager.sorter.getOrderedItems(), [manager]);
const editingItem = useAtomValue(editingPropertyAtom);
@@ -735,9 +735,13 @@ export const PagePropertiesTableHeader = ({
interface PagePropertyRowProps {
property: PageInfoCustomProperty;
style?: React.CSSProperties;
+ rowNameClassName?: string;
}
-const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
+export const PagePropertyRow = ({
+ property,
+ rowNameClassName,
+}: PagePropertyRowProps) => {
const manager = useContext(managerContext);
const meta = manager.getCustomPropertyMeta(property.id);
@@ -772,7 +776,10 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
{...attributes}
{...listeners}
data-testid="page-property-row-name"
- className={styles.sortablePropertyRowNameCell}
+ className={clsx(
+ styles.sortablePropertyRowNameCell,
+ rowNameClassName
+ )}
onClick={handleEditMeta}
>
@@ -790,7 +797,11 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
);
};
-const PageTagsRow = () => {
+export const PageTagsRow = ({
+ rowNameClassName,
+}: {
+ rowNameClassName?: string;
+}) => {
const t = useI18n();
return (
{
data-property="tags"
>
@@ -1074,7 +1085,7 @@ const PagePropertiesTableInner = () => {
);
};
-const usePagePropertiesManager = (page: Doc) => {
+export const usePagePropertiesManager = (page: Doc) => {
// the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component
diff --git a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx
index cf28fe666d..4ca3061497 100644
--- a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx
+++ b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx
@@ -30,7 +30,7 @@ interface InlineTagsListProps
onRemove?: () => void;
}
-const InlineTagsList = ({
+export const InlineTagsList = ({
pageId,
readonly,
children,
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/info/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/info/index.tsx
new file mode 100644
index 0000000000..52b6263652
--- /dev/null
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/info/index.tsx
@@ -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 (
+
+ }
+ />
+
+ );
+};
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx
index 5dc6b5a3b3..3e14e208f3 100644
--- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx
@@ -6,7 +6,10 @@ import {
MenuSeparator,
MenuSub,
} 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 { ShareMenuContent } from '@affine/core/components/affine/share-page-modal/share-menu';
import { Export, MoveToTrash } from '@affine/core/components/page-list';
@@ -27,6 +30,7 @@ import {
FavoriteIcon,
HistoryIcon,
ImportIcon,
+ InformationIcon,
PageIcon,
ShareIcon,
} from '@blocksuite/icons/rc';
@@ -83,6 +87,11 @@ export const PageHeaderMenuButton = ({
return setOpenHistoryTipsModal(true);
}, [setOpenHistoryTipsModal, workspace.flavour]);
+ const setOpenInfoModal = useSetAtom(openInfoModalAtom);
+ const openInfoModal = () => {
+ setOpenInfoModal(true);
+ };
+
const handleOpenTrashModal = useCallback(() => {
setTrashModal({
open: true,
@@ -236,6 +245,35 @@ export const PageHeaderMenuButton = ({
{t['com.affine.header.option.add-tag']()}
*/}
+ {runtimeConfig.enableInfoModal ? (
+
+ ) : null}
+ {runtimeConfig.enablePageHistory ? (
+
+ ) : null}
+
{!isJournal && (
+ {runtimeConfig.enableInfoModal ? (
+
+
+
+ }
+ >
+ {t['com.affine.page-properties.page-info.view']()}
+
+ ) : null}
{environment.isDesktop && appSettings.enableMultiView ? (
+ {blocksuiteDoc ? (
+
+ ) : null}
void;
onDelete: () => void;
onOpenInSplitView: () => void;
+ onOpenInfoModal: () => void;
};
export const OperationItems = ({
@@ -36,6 +38,7 @@ export const OperationItems = ({
onRemoveFromFavourites,
onDelete,
onOpenInSplitView,
+ onOpenInfoModal,
}: OperationItemsProps) => {
const { appSettings } = useAppSettingHelper();
const t = useI18n();
@@ -63,6 +66,19 @@ export const OperationItems = ({
name: t['Rename'](),
click: onRename,
},
+ ...(runtimeConfig.enableInfoModal
+ ? [
+ {
+ icon: (
+
+
+
+ ),
+ name: t['com.affine.page-properties.page-info.view'](),
+ click: onOpenInfoModal,
+ },
+ ]
+ : []),
{
icon: (
@@ -123,7 +139,7 @@ export const OperationItems = ({
),
- name: t['com.affine.trashOperation.delete'](),
+ name: t['com.affine.moveToTrash.title'](),
click: onDelete,
type: 'danger',
},
@@ -139,6 +155,7 @@ export const OperationItems = ({
onRemoveFromAllowList,
appSettings.enableMultiView,
onOpenInSplitView,
+ onOpenInfoModal,
onDelete,
]
);
diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx
index 846fba7c9a..9b611662c3 100644
--- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx
+++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx
@@ -1,12 +1,13 @@
import { toast } from '@affine/component';
import { IconButton } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu';
+import { InfoModal } from '@affine/core/components/affine/page-properties';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
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 { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
@@ -33,9 +34,12 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
isReferencePage,
} = props;
const t = useI18n();
+ const [openInfoModal, setOpenInfoModal] = useState(false);
+
const { workspaceService } = useServices({
WorkspaceService,
});
+ const page = workspaceService.workspace.docCollection.getDoc(pageId);
const { createLinkedPage } = usePageHelper(
workspaceService.workspace.docCollection
);
@@ -76,30 +80,45 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
workbench.openDoc(pageId, { at: 'tail' });
}, [pageId, workbench]);
+ const handleOpenInfoModal = useCallback(() => {
+ setOpenInfoModal(true);
+ }, [setOpenInfoModal]);
+
return (
-
- }
- >
-
+
+ }
>
-
-
-
+
+
+
+
+ {page ? (
+
+ ) : null}
+ >
);
};
diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx
index ac2ed9b409..147c5bf1c5 100644
--- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx
+++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx
@@ -1,4 +1,5 @@
import { toast } from '@affine/component';
+import { openInfoModalAtom } from '@affine/core/atoms';
import {
PreconditionStrategy,
registerAffineCommand,
@@ -36,6 +37,7 @@ export function useRegisterBlocksuiteEditorCommands() {
const trash = useLiveData(doc.trash$);
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
+ const setInfoModalState = useSetAtom(openInfoModalAtom);
const openHistoryModal = useCallback(() => {
setPageHistoryModalState(() => ({
@@ -44,8 +46,11 @@ export function useRegisterBlocksuiteEditorCommands() {
}));
}, [docId, setPageHistoryModalState]);
- const { restoreFromTrash, duplicate } =
- useBlockSuiteMetaHelper(docCollection);
+ const openInfoModal = useCallback(() => {
+ setInfoModalState(true);
+ }, [setInfoModalState]);
+
+ const { duplicate } = useBlockSuiteMetaHelper(docCollection);
const exportHandler = useExportPage(doc.blockSuiteDoc);
const { setTrashModal } = useTrashModalHelper(docCollection);
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' ? : ,
+ label: t['com.affine.page-properties.page-info.view'](),
+ run() {
+ openInfoModal();
+ },
+ })
+ );
+
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-${favorite ? 'remove-from' : 'add-to'}-favourites`,
@@ -270,7 +291,6 @@ export function useRegisterBlocksuiteEditorCommands() {
mode,
onClickDelete,
exportHandler,
- restoreFromTrash,
t,
trash,
isCloudWorkspace,
@@ -280,5 +300,6 @@ export function useRegisterBlocksuiteEditorCommands() {
docId,
doc,
telemetry,
+ openInfoModal,
]);
}
diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts
index 98974cc607..3b787fd3fa 100644
--- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts
+++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts
@@ -18,3 +18,9 @@ export const journalWeekPicker = style({
alignItems: 'center',
justifyContent: 'center',
});
+
+export const iconButtonContainer = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+});
diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx
index b30a39cdbb..beffb8f5c5 100644
--- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx
+++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx
@@ -1,5 +1,8 @@
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 { InfoButton } from '@affine/core/components/blocksuite/block-suite-header/info';
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 { 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 type { Doc } from '@blocksuite/store';
import { type Workspace } from '@toeverything/infra';
-import { useAtomValue } from 'jotai';
+import { useAtom, useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
import { SharePageButton } from '../../../components/affine/share-page-modal';
@@ -90,8 +93,16 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
pageId={page?.id}
docCollection={workspace.docCollection}
/>
- {hideCollect ? null : }
-
+
+ {hideCollect ? null : (
+ <>
+
+ {runtimeConfig.enableInfoModal ?
: null}
+ >
+ )}
+
+
+
{!hidePresent ? : null}
@@ -111,15 +122,26 @@ export function DetailPageHeader(props: PageHeaderProps) {
const { page, workspace } = props;
const { isJournal } = useJournalInfoHelper(page.collection, page.id);
const isInTrash = page.meta?.trash;
+ const [openInfoModal, setOpenInfoModal] = useAtom(openInfoModalAtom);
useRegisterCopyLinkCommands({
workspaceMeta: workspace.meta,
docId: page.id,
});
- return isJournal && !isInTrash ? (
-
- ) : (
-
+ return (
+ <>
+ {isJournal && !isInTrash ? (
+
+ ) : (
+
+ )}
+
+ >
);
}
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 729c45b219..debaa364a6 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -846,6 +846,7 @@
"com.affine.page-properties.create-property.menu.header": "Type",
"com.affine.page-properties.icons": "Icons",
"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.always-hide": "Always hide",
"com.affine.page-properties.property.always-show": "Always show",
diff --git a/tests/affine-local/e2e/doc-info-modal.spec.ts b/tests/affine-local/e2e/doc-info-modal.spec.ts
new file mode 100644
index 0000000000..44399cc3d0
--- /dev/null
+++ b/tests/affine-local/e2e/doc-info-modal.spec.ts
@@ -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');
+});
diff --git a/tests/affine-local/e2e/local-first-collections-items.spec.ts b/tests/affine-local/e2e/local-first-collections-items.spec.ts
index 55d7ff54f3..c4be5b5e17 100644
--- a/tests/affine-local/e2e/local-first-collections-items.spec.ts
+++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts
@@ -76,7 +76,7 @@ test('Show collections items in sidebar', async ({ page }) => {
await collectionPage
.getByTestId('left-sidebar-page-operation-button')
.click();
- const deletePage = page.getByText('Delete');
+ const deletePage = page.getByText('Move to Trash');
await deletePage.click();
await page.getByTestId('confirm-delete-page').click();
expect(await collections.getByTestId('collection-page').count()).toBe(0);
diff --git a/tools/cli/src/webpack/runtime-config.ts b/tools/cli/src/webpack/runtime-config.ts
index 588a7e6735..360fc2ba28 100644
--- a/tools/cli/src/webpack/runtime-config.ts
+++ b/tools/cli/src/webpack/runtime-config.ts
@@ -23,6 +23,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enablePayment: true,
enablePageHistory: true,
enableExperimentalFeature: false,
+ enableInfoModal: false,
allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false,
serverUrlPrefix: 'https://app.affine.pro',
appVersion: packageJson.version,
@@ -63,6 +64,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enablePayment: true,
enablePageHistory: true,
enableExperimentalFeature: true,
+ enableInfoModal: true,
allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false,
serverUrlPrefix: 'https://affine.fail',
appVersion: packageJson.version,