feat(core): rewrite page-mode-switch with RadioGroup, bind hotkey with cmdk (#7758)

close AF-1170

- bump `@toeverything/theme`
- refactor page-mode-switch
  - use global `<RadioGroup />`
  - reuse for doc history
  - remove `styled` usage
  - bind hotkey via cmdk
- Update `<RadioGroup />` color scheme with latest design system
- Update right sidebar header tab style
- Update tooltip with shortcut for app nav button
This commit is contained in:
CatsJuice
2024-08-12 03:56:56 +00:00
parent 4ac9bd7790
commit 75e02bb088
18 changed files with 210 additions and 302 deletions
@@ -34,11 +34,7 @@ import { encodeStateAsUpdate } from 'yjs';
import { pageHistoryModalAtom } from '../../../atoms/page-history';
import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style';
import {
EdgelessSwitchItem,
PageSwitchItem,
} from '../../blocksuite/block-suite-mode-switch/switch-items';
import { PureEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch';
import { AffineErrorBoundary } from '../affine-error-boundary';
import {
historyListGroupByDay,
@@ -108,15 +104,11 @@ const HistoryEditorPreview = ({
mode,
title,
}: HistoryEditorPreviewProps) => {
const { onSwitchToPageMode, onSwitchToEdgelessMode } = useMemo(
() => ({
onSwitchToPageMode: () => {
onModeChange('page');
},
onSwitchToEdgelessMode: () => {
onModeChange('edgeless');
},
}),
const onModeChangeWithTrack = useCallback(
(mode: DocMode) => {
track.$.docHistory.$.switchPageMode({ mode });
onModeChange(mode);
},
[onModeChange]
);
@@ -124,22 +116,7 @@ const HistoryEditorPreview = ({
return (
<div className={styles.previewContent}>
<div className={styles.previewHeader}>
<StyledEditorModeSwitch switchLeft={mode === 'page'}>
<PageSwitchItem
data-testid="switch-page-mode-button"
active={mode === 'page'}
data-event-props="$.docHistory.$.switchPageMode"
data-event-args-type="page"
onClick={onSwitchToPageMode}
/>
<EdgelessSwitchItem
data-testid="switch-edgeless-mode-button"
active={mode === 'edgeless'}
data-event-props="$.docHistory.$.switchPageMode"
data-event-args-type="edgeless"
onClick={onSwitchToEdgelessMode}
/>
</StyledEditorModeSwitch>
<PureEditorModeSwitch mode={mode} setMode={onModeChangeWithTrack} />
<div className={styles.previewHeaderTitle}>{title}</div>
<div className={styles.previewHeaderTimestamp}>
{ts
@@ -170,14 +147,7 @@ const HistoryEditorPreview = ({
)}
</div>
);
}, [
mode,
onSwitchToEdgelessMode,
onSwitchToPageMode,
snapshotPage,
title,
ts,
]);
}, [mode, onModeChangeWithTrack, snapshotPage, title, ts]);
return (
<div className={styles.previewWrapper}>
@@ -1,87 +1,69 @@
import { Tooltip } from '@affine/component/ui/tooltip';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { RadioGroup, type RadioItem, toast, Tooltip } from '@affine/component';
import { registerAffineCommand } from '@affine/core/commands';
import { track } from '@affine/core/mixpanel';
import { useI18n } from '@affine/i18n';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
import {
type DocMode,
DocService,
DocsService,
useLiveData,
useService,
} from '@toeverything/infra';
import type { CSSProperties } from 'react';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import type { DocCollection } from '../../../shared';
import { toast } from '../../../utils';
import { StyledEditorModeSwitch } from './style';
import { switchItem } from './style.css';
import { EdgelessSwitchItem, PageSwitchItem } from './switch-items';
export type EditorModeSwitchProps = {
// todo(himself65): combine these two properties
docCollection: DocCollection;
export interface EditorModeSwitchProps {
pageId: string;
style?: CSSProperties;
isPublic?: boolean;
publicMode?: DocMode;
}
const EdgelessRadioItem: RadioItem = {
value: 'edgeless',
label: <EdgelessSwitchItem />,
testId: 'switch-edgeless-mode-button',
className: switchItem,
};
const PageRadioItem: RadioItem = {
value: 'page',
label: <PageSwitchItem />,
testId: 'switch-page-mode-button',
className: switchItem,
};
export const EditorModeSwitch = ({
style,
docCollection,
pageId,
isPublic,
publicMode,
}: EditorModeSwitchProps) => {
const t = useI18n();
const pageMeta = useBlockSuiteDocMeta(docCollection).find(
meta => meta.id === pageId
);
const trash = pageMeta?.trash ?? false;
const doc = useService(DocService).doc;
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(pageId));
const trash = useLiveData(doc?.trash$);
const currentMode = useLiveData(doc?.mode$);
const currentMode = useLiveData(doc.mode$);
useEffect(() => {
if (trash || isPublic) {
return;
}
const keydown = (e: KeyboardEvent) => {
if (e.code === 'KeyS' && e.altKey) {
e.preventDefault();
doc.toggleMode();
toast(
currentMode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']()
);
}
};
document.addEventListener('keydown', keydown, { capture: true });
return () =>
document.removeEventListener('keydown', keydown, { capture: true });
}, [currentMode, isPublic, doc, pageId, t, trash]);
const onSwitchToPageMode = useCallback(() => {
track.$.header.actions.switchPageMode({
mode: 'page',
});
if (currentMode === 'page' || isPublic) {
return;
}
doc.setMode('page');
const togglePage = useCallback(() => {
if (currentMode === 'page' || isPublic || trash) return;
doc?.setMode('page');
toast(t['com.affine.toastMessage.pageMode']());
}, [currentMode, isPublic, doc, t]);
track.$.header.actions.switchPageMode({ mode: 'page' });
}, [currentMode, doc, isPublic, t, trash]);
const onSwitchToEdgelessMode = useCallback(() => {
track.$.header.actions.switchPageMode({
mode: 'edgeless',
});
if (currentMode === 'edgeless' || isPublic) {
return;
}
doc.setMode('edgeless');
const toggleEdgeless = useCallback(() => {
if (currentMode === 'edgeless' || isPublic || trash) return;
doc?.setMode('edgeless');
toast(t['com.affine.toastMessage.edgelessMode']());
}, [currentMode, isPublic, doc, t]);
track.$.header.actions.switchPageMode({ mode: 'edgeless' });
}, [currentMode, doc, isPublic, t, trash]);
const onModeChange = useCallback(
(mode: DocMode) => {
mode === 'page' ? togglePage() : toggleEdgeless();
},
[toggleEdgeless, togglePage]
);
const shouldHide = useCallback(
(mode: DocMode) =>
@@ -89,40 +71,73 @@ export const EditorModeSwitch = ({
[currentMode, isPublic, publicMode, trash]
);
const shouldActive = useCallback(
(mode: DocMode) => (isPublic ? false : currentMode === mode),
[currentMode, isPublic]
);
useEffect(() => {
if (trash || isPublic || currentMode === undefined) return;
return registerAffineCommand({
id: 'affine:doc-mode-switch',
category: 'editor:page',
label:
currentMode === 'page'
? t['com.affine.cmdk.switch-to-edgeless']()
: t['com.affine.cmdk.switch-to-page'](),
icon: currentMode === 'page' ? <EdgelessIcon /> : <PageIcon />,
keyBinding: {
binding: 'Alt+KeyS',
capture: true,
},
run: () => onModeChange(currentMode === 'edgeless' ? 'page' : 'edgeless'),
});
}, [currentMode, isPublic, onModeChange, t, trash]);
return (
<Tooltip
content={t['Switch']()}
shortcut={['$alt', 'S']}
side="bottom"
options={{
hidden: isPublic || trash,
}}
options={{ hidden: isPublic || trash }}
>
<StyledEditorModeSwitch
style={style}
switchLeft={currentMode === 'page'}
showAlone={trash || isPublic}
>
<PageSwitchItem
data-testid="switch-page-mode-button"
active={shouldActive('page')}
hide={shouldHide('page')}
trash={trash}
onClick={onSwitchToPageMode}
<div>
<PureEditorModeSwitch
mode={currentMode}
setMode={onModeChange}
hidePage={shouldHide('page')}
hideEdgeless={shouldHide('edgeless')}
/>
<EdgelessSwitchItem
data-testid="switch-edgeless-mode-button"
active={shouldActive('edgeless')}
hide={shouldHide('edgeless')}
trash={trash}
onClick={onSwitchToEdgelessMode}
/>
</StyledEditorModeSwitch>
</div>
</Tooltip>
);
};
export interface PureEditorModeSwitchProps {
mode?: DocMode;
setMode: (mode: DocMode) => void;
hidePage?: boolean;
hideEdgeless?: boolean;
}
export const PureEditorModeSwitch = ({
mode,
setMode,
hidePage,
hideEdgeless,
}: PureEditorModeSwitchProps) => {
const items = useMemo(
() => [
...(hidePage ? [] : [PageRadioItem]),
...(hideEdgeless ? [] : [EdgelessRadioItem]),
],
[hideEdgeless, hidePage]
);
return (
<RadioGroup
iconMode
itemHeight={24}
borderRadius={8}
padding={4}
gap={8}
value={mode}
items={items}
onChange={setMode}
/>
);
};
@@ -0,0 +1,10 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const switchItem = style({
width: 24,
height: 24,
});
// a workaround to override lottie svg stroke with default radio button color schemes
globalStyle(`${switchItem} svg path`, {
stroke: 'currentColor',
});
@@ -1,62 +0,0 @@
import { displayFlex, styled } from '@affine/component';
// TODO(@CatsJuice): refactor this component
export const StyledEditorModeSwitch = styled('div')<{
switchLeft: boolean;
showAlone?: boolean;
}>(({ switchLeft, showAlone }) => {
return {
maxWidth: showAlone ? '40px' : '70px',
gap: '8px',
height: '32px',
background: showAlone
? 'transparent'
: 'var(--affine-background-secondary-color)',
borderRadius: '8px',
...displayFlex('space-between', 'center'),
padding: '4px 4px',
position: 'relative',
'::after': {
content: '""',
display: showAlone ? 'none' : 'block',
width: '24px',
height: '24px',
background: 'var(--affine-background-primary-color)',
boxShadow: 'var(--affine-shadow-1)',
borderRadius: '4px',
zIndex: 1,
position: 'absolute',
transform: `translateX(${switchLeft ? '0' : '32px'})`,
transition: 'all .15s',
},
};
});
export const StyledSwitchItem = styled('button')<{
active?: boolean;
hide?: boolean;
trash?: boolean;
}>(({ active = false, hide = false, trash = false }) => {
return {
width: '24px',
height: '24px',
borderRadius: '8px',
WebkitAppRegion: 'no-drag',
boxShadow: active ? 'var(--affine-shadow-1)' : 'none',
color: active
? trash
? 'var(--affine-error-color)'
: 'var(--affine-primary-color)'
: 'var(--affine-icon-color)',
display: hide ? 'none' : 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
zIndex: 2,
fontSize: '20px',
path: {
stroke: 'currentColor',
},
};
});
@@ -5,35 +5,23 @@ import { cloneElement, useState } from 'react';
import edgelessHover from './animation-data/edgeless-hover.json';
import pageHover from './animation-data/page-hover.json';
import { StyledSwitchItem } from './style';
type HoverAnimateControllerProps = {
active?: boolean;
hide?: boolean;
trash?: boolean;
children: React.ReactElement;
} & HTMLAttributes<HTMLButtonElement>;
} & HTMLAttributes<HTMLDivElement>;
const HoverAnimateController = ({
active,
hide,
trash,
children,
...props
}: HoverAnimateControllerProps) => {
const [startAnimate, setStartAnimate] = useState(false);
return (
<StyledSwitchItem
hide={hide}
active={active}
data-active={active}
trash={trash}
onMouseEnter={() => {
setStartAnimate(true);
}}
onMouseLeave={() => {
setStartAnimate(false);
}}
<div
onMouseEnter={() => setStartAnimate(true)}
onMouseLeave={() => setStartAnimate(false)}
{...props}
>
{cloneElement(children, {
@@ -42,7 +30,7 @@ const HoverAnimateController = ({
width: 20,
height: 20,
})}
</StyledSwitchItem>
</div>
);
};
@@ -2,9 +2,8 @@ import { IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { useGeneralShortcuts } from '../../../hooks/affine/use-shortcuts';
import { NavigatorService } from '../services/navigator';
import * as styles from './navigation-buttons.css';
@@ -13,22 +12,6 @@ const tooltipSideBottom = { side: 'bottom' as const };
export const NavigationButtons = () => {
const t = useI18n();
const shortcuts = useGeneralShortcuts().shortcuts;
const shortcutsObject = useMemo(() => {
const goBack = t['com.affine.keyboardShortcuts.goBack']();
const goBackShortcut = shortcuts?.[goBack];
const goForward = t['com.affine.keyboardShortcuts.goForward']();
const goForwardShortcut = shortcuts?.[goForward];
return {
goBack,
goBackShortcut,
goForward,
goForwardShortcut,
};
}, [shortcuts, t]);
const navigator = useService(NavigatorService).navigator;
const backable = useLiveData(navigator.backable$);
@@ -65,11 +48,11 @@ export const NavigationButtons = () => {
return null;
}
// TODO(@CatsJuice): tooltip with shortcut
return (
<div className={styles.container}>
<IconButton
tooltip={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
tooltip={t['Go Back']()}
tooltipShortcut={['$mod', '[']}
tooltipOptions={tooltipSideBottom}
className={styles.button}
data-testid="app-navigation-button-back"
@@ -80,7 +63,8 @@ export const NavigationButtons = () => {
<ArrowLeftSmallIcon />
</IconButton>
<IconButton
tooltip={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
tooltip={t['Go Forward']()}
tooltipShortcut={['$mod', ']']}
tooltipOptions={tooltipSideBottom}
className={styles.button}
data-testid="app-navigation-button-forward"
@@ -289,6 +289,12 @@ const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => {
if (fragment === '$mod') {
return isMacOS ? '⌘' : 'Ctrl';
}
if (fragment === 'Alt') {
return isMacOS ? '⌥' : 'Alt';
}
if (fragment.startsWith('Key')) {
return fragment.slice(3);
}
if (fragment === 'ArrowUp') {
return '↑';
}
@@ -1,6 +1,5 @@
import { RadioGroup } from '@affine/component';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback } from 'react';
import { ViewService } from '../../services/view';
@@ -35,6 +34,7 @@ export const SidebarHeaderSwitcher = () => {
return tabItems.length ? (
<RadioGroup
iconMode
borderRadius={8}
itemHeight={24}
padding={4}
@@ -42,7 +42,6 @@ export const SidebarHeaderSwitcher = () => {
items={tabItems}
value={activeTab?.id}
onChange={handleActiveTabChange}
activeItemStyle={{ color: cssVar('primaryColor') }}
/>
) : null;
};
@@ -17,12 +17,7 @@ export function ShareHeader({
}) {
return (
<div className={styles.header}>
<EditorModeSwitch
isPublic
docCollection={docCollection}
pageId={pageId}
publicMode={publishMode}
/>
<EditorModeSwitch isPublic pageId={pageId} publicMode={publishMode} />
<BlocksuiteHeaderTitle
docCollection={docCollection}
pageId={pageId}
@@ -81,10 +81,7 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
<Header className={styles.header} ref={containerRef}>
<ViewTitle title={title} />
<ViewIcon icon="journal" />
<EditorModeSwitch
docCollection={workspace.docCollection}
pageId={page?.id}
/>
<EditorModeSwitch pageId={page?.id} />
<div className={styles.journalWeekPicker}>
<JournalWeekDatePicker
docCollection={workspace.docCollection}
@@ -138,10 +135,7 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
<Header className={styles.header} ref={containerRef}>
<ViewTitle title={title} />
<ViewIcon icon={currentMode ?? 'page'} />
<EditorModeSwitch
docCollection={workspace.docCollection}
pageId={page?.id}
/>
<EditorModeSwitch pageId={page?.id} />
<BlocksuiteHeaderTitle
inputHandleRef={titleInputHandleRef}
pageId={page?.id}