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 ( +
+
+
+ +
+
{t['Tags']()}
+
+ } + > +
+ {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 ( +
+
+
{icon}
+ {name} +
+
{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 ? ( + + + + } + data-testid="editor-option-menu-info" + onSelect={openInfoModal} + style={menuItemStyle} + > + {t['com.affine.page-properties.page-info.view']()} + + ) : null} + {runtimeConfig.enablePageHistory ? ( + + + + } + data-testid="editor-option-menu-history" + onSelect={openHistoryModal} + style={menuItemStyle} + > + {t['com.affine.history.view-history-version']()} + + ) : null} + {!isJournal && ( - {runtimeConfig.enablePageHistory ? ( - - - - } - data-testid="editor-option-menu-history" - onSelect={openHistoryModal} - style={menuItemStyle} - > - {t['com.affine.history.view-history-version']()} - - ) : null} - { return ( { + setOpenInfoModal(true); + }; const onDisablePublicSharing = useCallback(() => { toast('Successfully disabled', { @@ -144,6 +152,18 @@ export const PageOperationCell = ({ ? t['com.affine.favoritePageOperation.remove']() : t['com.affine.favoritePageOperation.add']()} + {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,