feat(core): responsive detail page header (#7263)

This commit is contained in:
CatsJuice
2024-06-20 05:47:43 +00:00
parent 78429166da
commit 1fdfa834a0
15 changed files with 264 additions and 108 deletions

View File

@@ -7,19 +7,13 @@ import { ShareMenu } from './share-menu';
type SharePageModalProps = {
workspace: Workspace;
page: Doc;
isJournal?: boolean;
};
export const SharePageButton = ({
workspace,
page,
isJournal,
}: SharePageModalProps) => {
export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
const confirmEnableCloud = useEnableCloud();
return (
<ShareMenu
isJournal={isJournal}
workspaceMetadata={workspace.meta}
currentPage={page}
onEnableAffineCloud={() =>

View File

@@ -138,7 +138,7 @@ globalStyle(`${shareLinkStyle} > span`, {
globalStyle(`${shareLinkStyle} > div > svg`, {
color: cssVar('linkColor'),
});
export const journalShareButton = style({
export const shareButton = style({
height: 32,
padding: '0px 8px',
});

View File

@@ -1,27 +1,24 @@
import { Button } from '@affine/component/ui/button';
import { Divider } from '@affine/component/ui/divider';
import { Menu } from '@affine/component/ui/menu';
import { useRegisterCopyLinkCommands } from '@affine/core/hooks/affine/use-register-copy-link-commands';
import { useIsActiveView } from '@affine/core/modules/workbench';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { WebIcon } from '@blocksuite/icons/rc';
import type { Doc } from '@blocksuite/store';
import type { WorkspaceMetadata } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, type PropsWithChildren, type Ref } from 'react';
import * as styles from './index.css';
import { ShareExport } from './share-export';
import { SharePage } from './share-page';
export interface ShareMenuProps {
export interface ShareMenuProps extends PropsWithChildren {
workspaceMetadata: WorkspaceMetadata;
currentPage: Doc;
isJournal?: boolean;
onEnableAffineCloud: () => void;
}
const ShareMenuContent = (props: ShareMenuProps) => {
export const ShareMenuContent = (props: ShareMenuProps) => {
const t = useI18n();
return (
<div className={styles.containerStyle}>
@@ -40,8 +37,20 @@ const ShareMenuContent = (props: ShareMenuProps) => {
);
};
const LocalShareMenu = (props: ShareMenuProps) => {
const DefaultShareButton = forwardRef(function DefaultShareButton(
_,
ref: Ref<HTMLButtonElement>
) {
const t = useI18n();
return (
<Button ref={ref} className={styles.shareButton} type="primary">
{t['com.affine.share-menu.shareButton']()}
</Button>
);
});
const LocalShareMenu = (props: ShareMenuProps) => {
return (
<Menu
items={<ShareMenuContent {...props} />}
@@ -53,27 +62,14 @@ const LocalShareMenu = (props: ShareMenuProps) => {
modal: false,
}}
>
<Button
className={clsx({ [styles.journalShareButton]: props.isJournal })}
data-testid="local-share-menu-button"
type="primary"
>
{t['com.affine.share-menu.shareButton']()}
</Button>
<div data-testid="local-share-menu-button">
{props.children || <DefaultShareButton />}
</div>
</Menu>
);
};
const CloudShareMenu = (props: ShareMenuProps) => {
const t = useI18n();
// only enable copy link commands when the view is active and the workspace is cloud
const isActiveView = useIsActiveView();
useRegisterCopyLinkCommands({
workspaceId: props.workspaceMetadata.id,
docId: props.currentPage.id,
isActiveView,
});
return (
<Menu
items={<ShareMenuContent {...props} />}
@@ -85,13 +81,9 @@ const CloudShareMenu = (props: ShareMenuProps) => {
modal: false,
}}
>
<Button
className={clsx({ [styles.journalShareButton]: props.isJournal })}
data-testid="cloud-share-menu-button"
type="primary"
>
{t['com.affine.share-menu.shareButton']()}
</Button>
<div data-testid="cloud-share-menu-button">
{props.children || <DefaultShareButton />}
</div>
</Menu>
);
};

View File

@@ -4,14 +4,18 @@ import {
MenuIcon,
MenuItem,
MenuSeparator,
MenuSub,
} from '@affine/component/ui/menu';
import { openHistoryTipsModalAtom } 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';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud';
import { useExportPage } from '@affine/core/hooks/affine/use-export-page';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useDetailPageHeaderResponsive } from '@affine/core/pages/workspace/detail-page/use-header-responsive';
import { mixpanel } from '@affine/core/utils';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
@@ -24,7 +28,9 @@ import {
HistoryIcon,
ImportIcon,
PageIcon,
ShareIcon,
} from '@blocksuite/icons/rc';
import type { Doc } from '@blocksuite/store';
import {
DocService,
useLiveData,
@@ -40,16 +46,19 @@ import { useFavorite } from '../favorite';
type PageMenuProps = {
rename?: () => void;
pageId: string;
page: Doc;
isJournal?: boolean;
};
// fixme: refactor this file
export const PageHeaderMenuButton = ({
rename,
pageId,
page,
isJournal,
}: PageMenuProps) => {
const pageId = page?.id;
const t = useI18n();
const { hideShare } = useDetailPageHeaderResponsive();
const confirmEnableCloud = useEnableCloud();
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
@@ -127,8 +136,46 @@ export const PageHeaderMenuButton = ({
}
}, [importFile]);
const showResponsiveMenu = hideShare;
const ResponsiveMenuItems = (
<>
{hideShare ? (
<MenuSub
subContentOptions={{
sideOffset: 12,
alignOffset: -8,
}}
items={
<div style={{ padding: 4 }}>
<ShareMenuContent
workspaceMetadata={workspace.meta}
currentPage={page}
onEnableAffineCloud={() =>
confirmEnableCloud(workspace, {
openPageId: page.id,
})
}
/>
</div>
}
triggerOptions={{
preFix: (
<MenuIcon>
<ShareIcon />
</MenuIcon>
),
}}
>
{t['com.affine.share-menu.shareButton']()}
</MenuSub>
) : null}
<MenuSeparator />
</>
);
const EditMenu = (
<>
{showResponsiveMenu ? ResponsiveMenuItems : null}
{!isJournal && (
<MenuItem
preFix={

View File

@@ -0,0 +1,17 @@
import { IconButton } from '@affine/component';
import { PresentationIcon } from '@blocksuite/icons/rc';
import { usePresent } from './use-present';
export const DetailPageHeaderPresentButton = () => {
const { isPresent, handlePresent } = usePresent();
return (
<IconButton
style={{ flexShrink: 0 }}
size={'large'}
icon={<PresentationIcon />}
onClick={() => handlePresent(!isPresent)}
></IconButton>
);
};

View File

@@ -0,0 +1,59 @@
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import type { EdgelessRootService } from '@blocksuite/blocks';
import { useCallback, useEffect, useState } from 'react';
export const usePresent = () => {
const [isPresent, setIsPresent] = useState(false);
const [editor] = useActiveBlocksuiteEditor();
const handlePresent = useCallback(
(enable = true) => {
isPresent;
const editorHost = editor?.host;
if (!editorHost) return;
// TODO: use surfaceService subAtom
const enterOrLeavePresentationMode = () => {
const edgelessRootService = editorHost.spec.getService(
'affine:page'
) as EdgelessRootService;
if (!edgelessRootService) {
return;
}
const activeTool = edgelessRootService.tool.edgelessTool.type;
const isFrameNavigator = activeTool === 'frameNavigator';
if ((enable && isFrameNavigator) || (!enable && !isFrameNavigator))
return;
edgelessRootService.tool.setEdgelessTool({
type: enable ? 'frameNavigator' : 'default',
});
};
enterOrLeavePresentationMode();
setIsPresent(enable);
},
[editor?.host, isPresent]
);
useEffect(() => {
if (!isPresent) return;
const editorHost = editor?.host;
if (!editorHost) return;
const edgelessPage = editorHost?.querySelector('affine-edgeless-root');
if (!edgelessPage) return;
return edgelessPage.slots.edgelessToolUpdated.on(() => {
setIsPresent(edgelessPage.edgelessTool.type === 'frameNavigator');
}).dispose;
}, [editor?.host, isPresent]);
return {
isPresent,
handlePresent,
};
};

View File

@@ -11,7 +11,7 @@ export const StyledEditorModeSwitch = styled('div')<{
background: showAlone
? 'transparent'
: 'var(--affine-background-secondary-color)',
borderRadius: '12px',
borderRadius: '8px',
...displayFlex('space-between', 'center'),
padding: '4px 4px',
position: 'relative',
@@ -23,7 +23,7 @@ export const StyledEditorModeSwitch = styled('div')<{
height: '24px',
background: 'var(--affine-background-primary-color)',
boxShadow: 'var(--affine-shadow-1)',
borderRadius: '8px',
borderRadius: '4px',
zIndex: 1,
position: 'absolute',
transform: `translateX(${switchLeft ? '0' : '32px'})`,

View File

@@ -1,60 +1,19 @@
import { Button } from '@affine/component/ui/button';
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { useI18n } from '@affine/i18n';
import type { EdgelessRootService } from '@blocksuite/blocks';
import { PresentationIcon } from '@blocksuite/icons/rc';
import { useCallback, useEffect, useState } from 'react';
import { usePresent } from '../../blocksuite/block-suite-header/present/use-present';
import * as styles from './styles.css';
export const PresentButton = () => {
const t = useI18n();
const [isPresent, setIsPresent] = useState(false);
const [editor] = useActiveBlocksuiteEditor();
const handlePresent = useCallback(() => {
const editorHost = editor?.host;
if (!editorHost || isPresent) return;
// TODO: use surfaceService subAtom
const enterPresentationMode = () => {
const edgelessRootService = editorHost.spec.getService(
'affine:page'
) as EdgelessRootService;
if (
!edgelessRootService ||
edgelessRootService.tool.edgelessTool.type === 'frameNavigator'
) {
return;
}
edgelessRootService.tool.setEdgelessTool({ type: 'frameNavigator' });
};
enterPresentationMode();
setIsPresent(true);
}, [editor?.host, isPresent]);
useEffect(() => {
if (!isPresent) return;
const editorHost = editor?.host;
if (!editorHost) return;
const edgelessPage = editorHost?.querySelector('affine-edgeless-root');
if (!edgelessPage) return;
return edgelessPage.slots.edgelessToolUpdated.on(() => {
setIsPresent(edgelessPage.edgelessTool.type === 'frameNavigator');
}).dispose;
}, [editor?.host, isPresent]);
const { isPresent, handlePresent } = usePresent();
return (
<Button
icon={<PresentationIcon />}
className={styles.presentButton}
onClick={handlePresent}
onClick={() => handlePresent()}
disabled={isPresent}
withoutHoverStyle
>

View File

@@ -1,16 +1,20 @@
import { registerAffineCommand } from '@affine/core/commands';
import { useSharingUrl } from '@affine/core/hooks/affine/use-share-url';
import { useIsActiveView } from '@affine/core/modules/workbench';
import { WorkspaceFlavour } from '@affine/env/workspace';
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useEffect } from 'react';
export function useRegisterCopyLinkCommands({
workspaceId,
workspaceMeta,
docId,
isActiveView,
}: {
workspaceId: string;
workspaceMeta: WorkspaceMetadata;
docId: string;
isActiveView: boolean;
}) {
const isActiveView = useIsActiveView();
const workspaceId = workspaceMeta.id;
const isCloud = workspaceMeta.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const { onClickCopyLink } = useSharingUrl({
workspaceId,
pageId: docId,
@@ -31,12 +35,12 @@ export function useRegisterCopyLinkCommands({
label: '',
icon: null,
run() {
isActiveView && onClickCopyLink();
isActiveView && isCloud && onClickCopyLink();
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [docId, isActiveView, onClickCopyLink]);
}, [docId, isActiveView, isCloud, onClickCopyLink]);
}

View File

@@ -45,6 +45,8 @@ export class View extends Entity {
);
size$ = new LiveData(100);
/** Width of header content in px (excludes sidebar-toggle/windows button/...) */
headerContentWidth$ = new LiveData(1920);
header = createIsland();
body = createIsland();

View File

@@ -58,6 +58,7 @@ export const rightSidebarButton = style({
export const viewHeaderContainer = style({
display: 'flex',
height: '100%',
width: 0,
flexGrow: 1,
minWidth: 12,
});

View File

@@ -1,9 +1,9 @@
import { IconButton } from '@affine/component';
import { IconButton, observeResize } from '@affine/component';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { RightSidebarIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtomValue } from 'jotai';
import { Suspense, useCallback } from 'react';
import { Suspense, useCallback, useEffect, useRef } from 'react';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai';
@@ -41,6 +41,7 @@ const ToggleButton = ({
};
export const RouteContainer = ({ route }: Props) => {
const viewHeaderContainerRef = useRef<HTMLDivElement | null>(null);
const view = useService(ViewService).view;
const viewPosition = useViewPosition();
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
@@ -51,6 +52,14 @@ export const RouteContainer = ({ route }: Props) => {
rightSidebar.toggle();
}, [rightSidebar]);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
useEffect(() => {
const container = viewHeaderContainerRef.current;
if (!container) return;
return observeResize(container, entry => {
view.headerContentWidth$.next(entry.contentRect.width);
});
}, [view.headerContentWidth$]);
return (
<div className={styles.root}>
<div className={styles.header}>
@@ -60,7 +69,10 @@ export const RouteContainer = ({ route }: Props) => {
className={styles.leftSidebarButton}
/>
)}
<view.header.Target className={styles.viewHeaderContainer} />
<view.header.Target
ref={viewHeaderContainerRef}
className={styles.viewHeaderContainer}
/>
{viewPosition.isLast && (
<>
{rightSidebarHasViews && (

View File

@@ -1,12 +1,14 @@
import type { InlineEditHandle } from '@affine/component';
import { Divider, type InlineEditHandle } from '@affine/component';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
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';
import { DetailPageHeaderPresentButton } from '@affine/core/components/blocksuite/block-suite-header/present/detail-header-present-button';
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch';
import { useRegisterCopyLinkCommands } from '@affine/core/hooks/affine/use-register-copy-link-commands';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import type { Doc } from '@blocksuite/store';
import type { Workspace } from '@toeverything/infra';
import { type Workspace } from '@toeverything/infra';
import { useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
@@ -15,6 +17,7 @@ import { appSidebarFloatingAtom } from '../../../components/app-sidebar';
import { BlocksuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header/title/index';
import { HeaderDivider } from '../../../components/pure/header';
import * as styles from './detail-page-header.css';
import { useDetailPageHeaderResponsive } from './use-header-responsive';
function Header({
children,
@@ -43,6 +46,7 @@ interface PageHeaderProps {
workspace: Workspace;
}
export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
const { hideShare, hideToday } = useDetailPageHeaderResponsive();
return (
<Header className={styles.header}>
<EditorModeSwitch
@@ -55,11 +59,13 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
page={page}
/>
</div>
<JournalTodayButton docCollection={workspace.docCollection} />
{hideToday ? null : (
<JournalTodayButton docCollection={workspace.docCollection} />
)}
<HeaderDivider />
<PageHeaderMenuButton isJournal pageId={page?.id} />
{page ? (
<SharePageButton isJournal workspace={workspace} page={page} />
<PageHeaderMenuButton isJournal page={page} />
{page && !hideShare ? (
<SharePageButton workspace={workspace} page={page} />
) : null}
</Header>
);
@@ -67,6 +73,8 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
const titleInputHandleRef = useRef<InlineEditHandle>(null);
const { hideCollect, hideShare, hidePresent, showDivider } =
useDetailPageHeaderResponsive();
const onRename = useCallback(() => {
setTimeout(() => titleInputHandleRef.current?.triggerEdit());
@@ -82,19 +90,33 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
pageId={page?.id}
docCollection={workspace.docCollection}
/>
<PageHeaderMenuButton rename={onRename} pageId={page?.id} />
<FavoriteButton pageId={page?.id} />
{hideCollect ? null : <FavoriteButton pageId={page?.id} />}
<PageHeaderMenuButton rename={onRename} page={page} />
<div className={styles.spacer} />
{page ? <SharePageButton workspace={workspace} page={page} /> : null}
{!hidePresent ? <DetailPageHeaderPresentButton /> : null}
{page && !hideShare ? (
<SharePageButton workspace={workspace} page={page} />
) : null}
{showDivider ? (
<Divider orientation="vertical" style={{ height: 20, marginLeft: 4 }} />
) : null}
</Header>
);
}
export function DetailPageHeader(props: PageHeaderProps) {
const { page } = props;
const { page, workspace } = props;
const { isJournal } = useJournalInfoHelper(page.collection, page.id);
const isInTrash = page.meta?.trash;
useRegisterCopyLinkCommands({
workspaceMeta: workspace.meta,
docId: page.id,
});
return isJournal && !isInTrash ? (
<JournalPageHeader {...props} />
) : (

View File

@@ -0,0 +1,36 @@
import { RightSidebarService } from '@affine/core/modules/right-sidebar';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { useViewPosition } from '@affine/core/modules/workbench/view/use-view-position';
import { DocService, useLiveData, useService } from '@toeverything/infra';
export const useDetailPageHeaderResponsive = () => {
const mode = useLiveData(useService(DocService).doc.mode$);
const view = useService(ViewService).view;
const workbench = useService(WorkbenchService).workbench;
const availableWidth = useLiveData(view.headerContentWidth$);
const viewPosition = useViewPosition();
const workbenchViewsCount = useLiveData(
workbench.views$.map(views => views.length)
);
const rightSidebar = useService(RightSidebarService).rightSidebar;
const rightSidebarOpen = useLiveData(rightSidebar.isOpen$);
// share button should be hidden once split-view is enabled
const hideShare = availableWidth < 500 || workbenchViewsCount > 1;
const hidePresent = availableWidth < 400 || mode !== 'edgeless';
const hideCollect = availableWidth < 300;
const hideToday = availableWidth < 300;
const showDivider =
viewPosition.isLast && !rightSidebarOpen && !(hidePresent && hideShare);
return {
hideShare,
hidePresent,
hideCollect,
hideToday,
showDivider,
};
};

View File

@@ -1,5 +1,11 @@
import { LiveData, useLiveData } from '@toeverything/infra';
import { useEffect, useRef } from 'react';
import {
forwardRef,
type Ref,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { createPortal } from 'react-dom';
export const createIsland = () => {
@@ -7,8 +13,13 @@ export const createIsland = () => {
let mounted = false;
let provided = false;
return {
Target: ({ ...other }: React.HTMLProps<HTMLDivElement>) => {
Target: forwardRef(function IslandTarget(
{ ...other }: React.HTMLProps<HTMLDivElement>,
ref: Ref<HTMLDivElement>
) {
const target = useRef<HTMLDivElement | null>(null);
useImperativeHandle(ref, () => target.current as HTMLDivElement, []);
useEffect(() => {
if (mounted === true) {
throw new Error('Island should not be mounted more than once');
@@ -21,7 +32,7 @@ export const createIsland = () => {
};
}, []);
return <div {...other} ref={target}></div>;
},
}),
Provider: ({ children }: React.PropsWithChildren) => {
const target = useLiveData(targetLiveData$);
useEffect(() => {