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:
@@ -47,7 +47,7 @@
|
|||||||
"@radix-ui/react-toolbar": "^1.0.4",
|
"@radix-ui/react-toolbar": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||||
"@toeverything/theme": "^1.0.2",
|
"@toeverything/theme": "^1.0.4",
|
||||||
"@vanilla-extract/dynamic": "^2.1.0",
|
"@vanilla-extract/dynamic": "^2.1.0",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"check-password-strength": "^2.0.10",
|
"check-password-strength": "^2.0.10",
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import * as RadixRadioGroup from '@radix-ui/react-radio-group';
|
import * as RadixRadioGroup from '@radix-ui/react-radio-group';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { createRef, memo, useCallback, useMemo, useRef } from 'react';
|
import {
|
||||||
|
createRef,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { withUnit } from '../../utils/with-unit';
|
import { withUnit } from '../../utils/with-unit';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
@@ -71,6 +78,7 @@ export const RadioGroup = memo(function RadioGroup({
|
|||||||
animationEasing = 'cubic-bezier(.18,.22,0,1)',
|
animationEasing = 'cubic-bezier(.18,.22,0,1)',
|
||||||
activeItemClassName,
|
activeItemClassName,
|
||||||
activeItemStyle,
|
activeItemStyle,
|
||||||
|
iconMode,
|
||||||
onChange,
|
onChange,
|
||||||
}: RadioProps) {
|
}: RadioProps) {
|
||||||
const animationTImerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const animationTImerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -128,23 +136,25 @@ export const RadioGroup = memo(function RadioGroup({
|
|||||||
[animationDuration, animationEasing, finalItems]
|
[animationDuration, animationEasing, finalItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onValueChange = useCallback(
|
// animate on value change
|
||||||
(newValue: string) => {
|
// useEffect: in case that value is changed from outside
|
||||||
const oldValue = value;
|
const prevValue = useRef(value);
|
||||||
if (oldValue !== newValue) {
|
useEffect(() => {
|
||||||
onChange(newValue);
|
const currentValue = value;
|
||||||
animate(oldValue, newValue);
|
const previousValue = prevValue.current;
|
||||||
}
|
if (currentValue !== previousValue) {
|
||||||
},
|
animate(previousValue, currentValue);
|
||||||
[animate, onChange, value]
|
prevValue.current = currentValue;
|
||||||
);
|
}
|
||||||
|
}, [animate, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadixRadioGroup.Root
|
<RadixRadioGroup.Root
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onChange}
|
||||||
className={styles.radioButtonGroup}
|
className={styles.radioButtonGroup}
|
||||||
style={finalStyle}
|
style={finalStyle}
|
||||||
|
data-icon-mode={iconMode}
|
||||||
>
|
>
|
||||||
{finalItems.map(({ customRender, ...item }, index) => {
|
{finalItems.map(({ customRender, ...item }, index) => {
|
||||||
const testId = item.testId ? { 'data-testid': item.testId } : {};
|
const testId = item.testId ? { 'data-testid': item.testId } : {};
|
||||||
@@ -173,9 +183,7 @@ export const RadioGroup = memo(function RadioGroup({
|
|||||||
ref={item.indicatorRef}
|
ref={item.indicatorRef}
|
||||||
/>
|
/>
|
||||||
<span className={styles.radioButtonContent}>
|
<span className={styles.radioButtonContent}>
|
||||||
{customRender
|
{customRender?.(item, index) ?? item.label ?? item.value}
|
||||||
? customRender(item, index)
|
|
||||||
: (item.label ?? item.value)}
|
|
||||||
</span>
|
</span>
|
||||||
</RadixRadioGroup.Item>
|
</RadixRadioGroup.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const outerPadding = createVar('radio-outer-padding');
|
export const outerPadding = createVar('radio-outer-padding');
|
||||||
@@ -16,16 +17,22 @@ export const radioButton = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVarV2('switch/fontColor/tertiary'),
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-state="checked"]': {
|
'&[data-state="checked"]': {
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVarV2('switch/fontColor/primary'),
|
||||||
},
|
},
|
||||||
'&[data-state="unchecked"]:hover': {
|
'&[data-state="unchecked"]:hover': {
|
||||||
background: cssVar('hoverColor'),
|
background: cssVarV2('switch/buttonBackground/hover'),
|
||||||
|
},
|
||||||
|
'[data-icon-mode=true] &': {
|
||||||
|
color: cssVarV2('switch/iconColor/default'),
|
||||||
|
},
|
||||||
|
'[data-icon-mode=true] &[data-state="checked"]': {
|
||||||
|
color: cssVarV2('switch/iconColor/active'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -37,7 +44,7 @@ globalStyle(`${radioButtonContent} > svg`, { display: 'block' });
|
|||||||
export const radioButtonGroup = style({
|
export const radioButtonGroup = style({
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
background: cssVar('hoverColorFilled'),
|
background: cssVarV2('switch/switchBackground/background'),
|
||||||
|
|
||||||
borderRadius: outerRadius,
|
borderRadius: outerRadius,
|
||||||
padding: outerPadding,
|
padding: outerPadding,
|
||||||
@@ -53,8 +60,8 @@ export const indicator = style({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
background: cssVar('white'),
|
background: cssVarV2('switch/buttonBackground/active'),
|
||||||
filter: 'drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.1))',
|
boxShadow: cssVar('buttonShadow'),
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transformOrigin: 'left',
|
transformOrigin: 'left',
|
||||||
selectors: {
|
selectors: {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export interface RadioProps extends RadioGroupItemProps {
|
|||||||
activeItemClassName?: string;
|
activeItemClassName?: string;
|
||||||
/** Customize active item's style */
|
/** Customize active item's style */
|
||||||
activeItemStyle?: CSSProperties;
|
activeItemStyle?: CSSProperties;
|
||||||
|
/**
|
||||||
|
* This prop is used to use a different color scheme
|
||||||
|
*/
|
||||||
|
iconMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadioItem {
|
export interface RadioItem {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"@sentry/integrations": "^7.109.0",
|
"@sentry/integrations": "^7.109.0",
|
||||||
"@sentry/react": "^8.0.0",
|
"@sentry/react": "^8.0.0",
|
||||||
"@sgtpooki/file-type": "^1.0.1",
|
"@sgtpooki/file-type": "^1.0.1",
|
||||||
"@toeverything/theme": "^1.0.2",
|
"@toeverything/theme": "^1.0.4",
|
||||||
"@vanilla-extract/dynamic": "^2.1.0",
|
"@vanilla-extract/dynamic": "^2.1.0",
|
||||||
"animejs": "^3.2.2",
|
"animejs": "^3.2.2",
|
||||||
"async-call-rpc": "^6.4.2",
|
"async-call-rpc": "^6.4.2",
|
||||||
|
|||||||
@@ -34,11 +34,7 @@ import { encodeStateAsUpdate } from 'yjs';
|
|||||||
|
|
||||||
import { pageHistoryModalAtom } from '../../../atoms/page-history';
|
import { pageHistoryModalAtom } from '../../../atoms/page-history';
|
||||||
import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
|
import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
|
||||||
import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style';
|
import { PureEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch';
|
||||||
import {
|
|
||||||
EdgelessSwitchItem,
|
|
||||||
PageSwitchItem,
|
|
||||||
} from '../../blocksuite/block-suite-mode-switch/switch-items';
|
|
||||||
import { AffineErrorBoundary } from '../affine-error-boundary';
|
import { AffineErrorBoundary } from '../affine-error-boundary';
|
||||||
import {
|
import {
|
||||||
historyListGroupByDay,
|
historyListGroupByDay,
|
||||||
@@ -108,15 +104,11 @@ const HistoryEditorPreview = ({
|
|||||||
mode,
|
mode,
|
||||||
title,
|
title,
|
||||||
}: HistoryEditorPreviewProps) => {
|
}: HistoryEditorPreviewProps) => {
|
||||||
const { onSwitchToPageMode, onSwitchToEdgelessMode } = useMemo(
|
const onModeChangeWithTrack = useCallback(
|
||||||
() => ({
|
(mode: DocMode) => {
|
||||||
onSwitchToPageMode: () => {
|
track.$.docHistory.$.switchPageMode({ mode });
|
||||||
onModeChange('page');
|
onModeChange(mode);
|
||||||
},
|
},
|
||||||
onSwitchToEdgelessMode: () => {
|
|
||||||
onModeChange('edgeless');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[onModeChange]
|
[onModeChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,22 +116,7 @@ const HistoryEditorPreview = ({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.previewContent}>
|
<div className={styles.previewContent}>
|
||||||
<div className={styles.previewHeader}>
|
<div className={styles.previewHeader}>
|
||||||
<StyledEditorModeSwitch switchLeft={mode === 'page'}>
|
<PureEditorModeSwitch mode={mode} setMode={onModeChangeWithTrack} />
|
||||||
<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>
|
|
||||||
<div className={styles.previewHeaderTitle}>{title}</div>
|
<div className={styles.previewHeaderTitle}>{title}</div>
|
||||||
<div className={styles.previewHeaderTimestamp}>
|
<div className={styles.previewHeaderTimestamp}>
|
||||||
{ts
|
{ts
|
||||||
@@ -170,14 +147,7 @@ const HistoryEditorPreview = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [
|
}, [mode, onModeChangeWithTrack, snapshotPage, title, ts]);
|
||||||
mode,
|
|
||||||
onSwitchToEdgelessMode,
|
|
||||||
onSwitchToPageMode,
|
|
||||||
snapshotPage,
|
|
||||||
title,
|
|
||||||
ts,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.previewWrapper}>
|
<div className={styles.previewWrapper}>
|
||||||
|
|||||||
@@ -1,87 +1,69 @@
|
|||||||
import { Tooltip } from '@affine/component/ui/tooltip';
|
import { RadioGroup, type RadioItem, toast, Tooltip } from '@affine/component';
|
||||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
import { registerAffineCommand } from '@affine/core/commands';
|
||||||
import { track } from '@affine/core/mixpanel';
|
import { track } from '@affine/core/mixpanel';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||||
import {
|
import {
|
||||||
type DocMode,
|
type DocMode,
|
||||||
DocService,
|
DocsService,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
useService,
|
useService,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import type { CSSProperties } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useCallback, useEffect } from 'react';
|
|
||||||
|
|
||||||
import type { DocCollection } from '../../../shared';
|
import { switchItem } from './style.css';
|
||||||
import { toast } from '../../../utils';
|
|
||||||
import { StyledEditorModeSwitch } from './style';
|
|
||||||
import { EdgelessSwitchItem, PageSwitchItem } from './switch-items';
|
import { EdgelessSwitchItem, PageSwitchItem } from './switch-items';
|
||||||
|
|
||||||
export type EditorModeSwitchProps = {
|
export interface EditorModeSwitchProps {
|
||||||
// todo(himself65): combine these two properties
|
|
||||||
docCollection: DocCollection;
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
style?: CSSProperties;
|
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
publicMode?: DocMode;
|
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 = ({
|
export const EditorModeSwitch = ({
|
||||||
style,
|
|
||||||
docCollection,
|
|
||||||
pageId,
|
pageId,
|
||||||
isPublic,
|
isPublic,
|
||||||
publicMode,
|
publicMode,
|
||||||
}: EditorModeSwitchProps) => {
|
}: EditorModeSwitchProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const pageMeta = useBlockSuiteDocMeta(docCollection).find(
|
const docsService = useService(DocsService);
|
||||||
meta => meta.id === pageId
|
const doc = useLiveData(docsService.list.doc$(pageId));
|
||||||
);
|
const trash = useLiveData(doc?.trash$);
|
||||||
const trash = pageMeta?.trash ?? false;
|
const currentMode = useLiveData(doc?.mode$);
|
||||||
const doc = useService(DocService).doc;
|
|
||||||
|
|
||||||
const currentMode = useLiveData(doc.mode$);
|
const togglePage = useCallback(() => {
|
||||||
|
if (currentMode === 'page' || isPublic || trash) return;
|
||||||
useEffect(() => {
|
doc?.setMode('page');
|
||||||
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');
|
|
||||||
toast(t['com.affine.toastMessage.pageMode']());
|
toast(t['com.affine.toastMessage.pageMode']());
|
||||||
}, [currentMode, isPublic, doc, t]);
|
track.$.header.actions.switchPageMode({ mode: 'page' });
|
||||||
|
}, [currentMode, doc, isPublic, t, trash]);
|
||||||
|
|
||||||
const onSwitchToEdgelessMode = useCallback(() => {
|
const toggleEdgeless = useCallback(() => {
|
||||||
track.$.header.actions.switchPageMode({
|
if (currentMode === 'edgeless' || isPublic || trash) return;
|
||||||
mode: 'edgeless',
|
doc?.setMode('edgeless');
|
||||||
});
|
|
||||||
if (currentMode === 'edgeless' || isPublic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
doc.setMode('edgeless');
|
|
||||||
toast(t['com.affine.toastMessage.edgelessMode']());
|
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(
|
const shouldHide = useCallback(
|
||||||
(mode: DocMode) =>
|
(mode: DocMode) =>
|
||||||
@@ -89,40 +71,73 @@ export const EditorModeSwitch = ({
|
|||||||
[currentMode, isPublic, publicMode, trash]
|
[currentMode, isPublic, publicMode, trash]
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldActive = useCallback(
|
useEffect(() => {
|
||||||
(mode: DocMode) => (isPublic ? false : currentMode === mode),
|
if (trash || isPublic || currentMode === undefined) return;
|
||||||
[currentMode, isPublic]
|
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 (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={t['Switch']()}
|
content={t['Switch']()}
|
||||||
shortcut={['$alt', 'S']}
|
shortcut={['$alt', 'S']}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
options={{
|
options={{ hidden: isPublic || trash }}
|
||||||
hidden: isPublic || trash,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<StyledEditorModeSwitch
|
<div>
|
||||||
style={style}
|
<PureEditorModeSwitch
|
||||||
switchLeft={currentMode === 'page'}
|
mode={currentMode}
|
||||||
showAlone={trash || isPublic}
|
setMode={onModeChange}
|
||||||
>
|
hidePage={shouldHide('page')}
|
||||||
<PageSwitchItem
|
hideEdgeless={shouldHide('edgeless')}
|
||||||
data-testid="switch-page-mode-button"
|
|
||||||
active={shouldActive('page')}
|
|
||||||
hide={shouldHide('page')}
|
|
||||||
trash={trash}
|
|
||||||
onClick={onSwitchToPageMode}
|
|
||||||
/>
|
/>
|
||||||
<EdgelessSwitchItem
|
</div>
|
||||||
data-testid="switch-edgeless-mode-button"
|
|
||||||
active={shouldActive('edgeless')}
|
|
||||||
hide={shouldHide('edgeless')}
|
|
||||||
trash={trash}
|
|
||||||
onClick={onSwitchToEdgelessMode}
|
|
||||||
/>
|
|
||||||
</StyledEditorModeSwitch>
|
|
||||||
</Tooltip>
|
</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 edgelessHover from './animation-data/edgeless-hover.json';
|
||||||
import pageHover from './animation-data/page-hover.json';
|
import pageHover from './animation-data/page-hover.json';
|
||||||
import { StyledSwitchItem } from './style';
|
|
||||||
|
|
||||||
type HoverAnimateControllerProps = {
|
type HoverAnimateControllerProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
trash?: boolean;
|
trash?: boolean;
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
} & HTMLAttributes<HTMLButtonElement>;
|
} & HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
const HoverAnimateController = ({
|
const HoverAnimateController = ({
|
||||||
active,
|
|
||||||
hide,
|
|
||||||
trash,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: HoverAnimateControllerProps) => {
|
}: HoverAnimateControllerProps) => {
|
||||||
const [startAnimate, setStartAnimate] = useState(false);
|
const [startAnimate, setStartAnimate] = useState(false);
|
||||||
return (
|
return (
|
||||||
<StyledSwitchItem
|
<div
|
||||||
hide={hide}
|
onMouseEnter={() => setStartAnimate(true)}
|
||||||
active={active}
|
onMouseLeave={() => setStartAnimate(false)}
|
||||||
data-active={active}
|
|
||||||
trash={trash}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setStartAnimate(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setStartAnimate(false);
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{cloneElement(children, {
|
{cloneElement(children, {
|
||||||
@@ -42,7 +30,7 @@ const HoverAnimateController = ({
|
|||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
})}
|
})}
|
||||||
</StyledSwitchItem>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { IconButton } from '@affine/component';
|
|||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
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 { NavigatorService } from '../services/navigator';
|
||||||
import * as styles from './navigation-buttons.css';
|
import * as styles from './navigation-buttons.css';
|
||||||
|
|
||||||
@@ -13,22 +12,6 @@ const tooltipSideBottom = { side: 'bottom' as const };
|
|||||||
export const NavigationButtons = () => {
|
export const NavigationButtons = () => {
|
||||||
const t = useI18n();
|
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 navigator = useService(NavigatorService).navigator;
|
||||||
|
|
||||||
const backable = useLiveData(navigator.backable$);
|
const backable = useLiveData(navigator.backable$);
|
||||||
@@ -65,11 +48,11 @@ export const NavigationButtons = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(@CatsJuice): tooltip with shortcut
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<IconButton
|
<IconButton
|
||||||
tooltip={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
|
tooltip={t['Go Back']()}
|
||||||
|
tooltipShortcut={['$mod', '[']}
|
||||||
tooltipOptions={tooltipSideBottom}
|
tooltipOptions={tooltipSideBottom}
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
data-testid="app-navigation-button-back"
|
data-testid="app-navigation-button-back"
|
||||||
@@ -80,7 +63,8 @@ export const NavigationButtons = () => {
|
|||||||
<ArrowLeftSmallIcon />
|
<ArrowLeftSmallIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
tooltip={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
|
tooltip={t['Go Forward']()}
|
||||||
|
tooltipShortcut={['$mod', ']']}
|
||||||
tooltipOptions={tooltipSideBottom}
|
tooltipOptions={tooltipSideBottom}
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
data-testid="app-navigation-button-forward"
|
data-testid="app-navigation-button-forward"
|
||||||
|
|||||||
@@ -289,6 +289,12 @@ const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => {
|
|||||||
if (fragment === '$mod') {
|
if (fragment === '$mod') {
|
||||||
return isMacOS ? '⌘' : 'Ctrl';
|
return isMacOS ? '⌘' : 'Ctrl';
|
||||||
}
|
}
|
||||||
|
if (fragment === 'Alt') {
|
||||||
|
return isMacOS ? '⌥' : 'Alt';
|
||||||
|
}
|
||||||
|
if (fragment.startsWith('Key')) {
|
||||||
|
return fragment.slice(3);
|
||||||
|
}
|
||||||
if (fragment === 'ArrowUp') {
|
if (fragment === 'ArrowUp') {
|
||||||
return '↑';
|
return '↑';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { RadioGroup } from '@affine/component';
|
import { RadioGroup } from '@affine/component';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { ViewService } from '../../services/view';
|
import { ViewService } from '../../services/view';
|
||||||
@@ -35,6 +34,7 @@ export const SidebarHeaderSwitcher = () => {
|
|||||||
|
|
||||||
return tabItems.length ? (
|
return tabItems.length ? (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
iconMode
|
||||||
borderRadius={8}
|
borderRadius={8}
|
||||||
itemHeight={24}
|
itemHeight={24}
|
||||||
padding={4}
|
padding={4}
|
||||||
@@ -42,7 +42,6 @@ export const SidebarHeaderSwitcher = () => {
|
|||||||
items={tabItems}
|
items={tabItems}
|
||||||
value={activeTab?.id}
|
value={activeTab?.id}
|
||||||
onChange={handleActiveTabChange}
|
onChange={handleActiveTabChange}
|
||||||
activeItemStyle={{ color: cssVar('primaryColor') }}
|
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,12 +17,7 @@ export function ShareHeader({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<EditorModeSwitch
|
<EditorModeSwitch isPublic pageId={pageId} publicMode={publishMode} />
|
||||||
isPublic
|
|
||||||
docCollection={docCollection}
|
|
||||||
pageId={pageId}
|
|
||||||
publicMode={publishMode}
|
|
||||||
/>
|
|
||||||
<BlocksuiteHeaderTitle
|
<BlocksuiteHeaderTitle
|
||||||
docCollection={docCollection}
|
docCollection={docCollection}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
|
|||||||
@@ -81,10 +81,7 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
|
|||||||
<Header className={styles.header} ref={containerRef}>
|
<Header className={styles.header} ref={containerRef}>
|
||||||
<ViewTitle title={title} />
|
<ViewTitle title={title} />
|
||||||
<ViewIcon icon="journal" />
|
<ViewIcon icon="journal" />
|
||||||
<EditorModeSwitch
|
<EditorModeSwitch pageId={page?.id} />
|
||||||
docCollection={workspace.docCollection}
|
|
||||||
pageId={page?.id}
|
|
||||||
/>
|
|
||||||
<div className={styles.journalWeekPicker}>
|
<div className={styles.journalWeekPicker}>
|
||||||
<JournalWeekDatePicker
|
<JournalWeekDatePicker
|
||||||
docCollection={workspace.docCollection}
|
docCollection={workspace.docCollection}
|
||||||
@@ -138,10 +135,7 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
|
|||||||
<Header className={styles.header} ref={containerRef}>
|
<Header className={styles.header} ref={containerRef}>
|
||||||
<ViewTitle title={title} />
|
<ViewTitle title={title} />
|
||||||
<ViewIcon icon={currentMode ?? 'page'} />
|
<ViewIcon icon={currentMode ?? 'page'} />
|
||||||
<EditorModeSwitch
|
<EditorModeSwitch pageId={page?.id} />
|
||||||
docCollection={workspace.docCollection}
|
|
||||||
pageId={page?.id}
|
|
||||||
/>
|
|
||||||
<BlocksuiteHeaderTitle
|
<BlocksuiteHeaderTitle
|
||||||
inputHandleRef={titleInputHandleRef}
|
inputHandleRef={titleInputHandleRef}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
|
|||||||
@@ -600,6 +600,8 @@
|
|||||||
"com.affine.cmdk.no-results": "No results found",
|
"com.affine.cmdk.no-results": "No results found",
|
||||||
"com.affine.cmdk.no-results-for": "No results found for",
|
"com.affine.cmdk.no-results-for": "No results found for",
|
||||||
"com.affine.cmdk.placeholder": "Type a command or search anything...",
|
"com.affine.cmdk.placeholder": "Type a command or search anything...",
|
||||||
|
"com.affine.cmdk.switch-to-edgeless": "Switch to $t(com.affine.edgelessMode)",
|
||||||
|
"com.affine.cmdk.switch-to-page": "Switch to $t(com.affine.pageMode)",
|
||||||
"com.affine.collection-bar.action.tooltip.delete": "Delete",
|
"com.affine.collection-bar.action.tooltip.delete": "Delete",
|
||||||
"com.affine.collection-bar.action.tooltip.edit": "Edit",
|
"com.affine.collection-bar.action.tooltip.edit": "Edit",
|
||||||
"com.affine.collection-bar.action.tooltip.pin": "Pin to sidebar",
|
"com.affine.collection-bar.action.tooltip.pin": "Pin to sidebar",
|
||||||
|
|||||||
@@ -1,54 +1,42 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export function locateModeSwitchButton(
|
||||||
|
page: Page,
|
||||||
|
mode: 'page' | 'edgeless',
|
||||||
|
active?: boolean
|
||||||
|
) {
|
||||||
|
// switch is implemented as RadioGroup button,
|
||||||
|
// so we can use aria-checked to determine the active state
|
||||||
|
const checkedSelector = active ? '[aria-checked="true"]' : '';
|
||||||
|
|
||||||
|
return page.locator(
|
||||||
|
`[data-testid="switch-${mode}-mode-button"]${checkedSelector}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function clickEdgelessModeButton(page: Page) {
|
export async function clickEdgelessModeButton(page: Page) {
|
||||||
await page.getByTestId('switch-edgeless-mode-button').click({
|
await locateModeSwitchButton(page, 'edgeless').click({ delay: 50 });
|
||||||
delay: 50,
|
await ensureInEdgelessMode(page);
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
page.locator(
|
|
||||||
'[data-testid="switch-edgeless-mode-button"][data-active="true"]'
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickPageModeButton(page: Page) {
|
export async function clickPageModeButton(page: Page) {
|
||||||
await page.getByTestId('switch-page-mode-button').click({
|
await locateModeSwitchButton(page, 'page').click({ delay: 50 });
|
||||||
delay: 50,
|
await ensureInPageMode(page);
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="switch-page-mode-button"][data-active="true"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureInPageMode(page: Page) {
|
export async function ensureInPageMode(page: Page) {
|
||||||
await expect(
|
await expect(locateModeSwitchButton(page, 'page', true)).toBeVisible();
|
||||||
page.locator('[data-testid="switch-page-mode-button"][data-active="true"]')
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureInEdgelessMode(page: Page) {
|
export async function ensureInEdgelessMode(page: Page) {
|
||||||
await expect(
|
await expect(locateModeSwitchButton(page, 'edgeless', true)).toBeVisible();
|
||||||
page.locator(
|
|
||||||
'[data-testid="switch-edgeless-mode-button"][data-active="true"]'
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPageMode(page: Page): Promise<'page' | 'edgeless'> {
|
export async function getPageMode(page: Page): Promise<'page' | 'edgeless'> {
|
||||||
if (
|
if (await locateModeSwitchButton(page, 'page', true).isVisible()) {
|
||||||
await page
|
|
||||||
.locator('[data-testid="switch-page-mode-button"][data-active="true"]')
|
|
||||||
.isVisible()
|
|
||||||
) {
|
|
||||||
return 'page';
|
return 'page';
|
||||||
}
|
}
|
||||||
if (
|
if (await locateModeSwitchButton(page, 'edgeless', true).isVisible()) {
|
||||||
await page
|
|
||||||
.locator(
|
|
||||||
'[data-testid="switch-edgeless-mode-button"][data-active="true"]'
|
|
||||||
)
|
|
||||||
.isVisible()
|
|
||||||
) {
|
|
||||||
return 'edgeless';
|
return 'edgeless';
|
||||||
}
|
}
|
||||||
throw new Error('Unknown mode');
|
throw new Error('Unknown mode');
|
||||||
|
|||||||
12
yarn.lock
12
yarn.lock
@@ -324,7 +324,7 @@ __metadata:
|
|||||||
"@storybook/test-runner": "npm:^0.19.0"
|
"@storybook/test-runner": "npm:^0.19.0"
|
||||||
"@storybook/testing-library": "npm:^0.2.2"
|
"@storybook/testing-library": "npm:^0.2.2"
|
||||||
"@testing-library/react": "npm:^16.0.0"
|
"@testing-library/react": "npm:^16.0.0"
|
||||||
"@toeverything/theme": "npm:^1.0.2"
|
"@toeverything/theme": "npm:^1.0.4"
|
||||||
"@types/bytes": "npm:^3.1.4"
|
"@types/bytes": "npm:^3.1.4"
|
||||||
"@types/react": "npm:^18.2.75"
|
"@types/react": "npm:^18.2.75"
|
||||||
"@types/react-dnd": "npm:^3.0.2"
|
"@types/react-dnd": "npm:^3.0.2"
|
||||||
@@ -419,7 +419,7 @@ __metadata:
|
|||||||
"@sgtpooki/file-type": "npm:^1.0.1"
|
"@sgtpooki/file-type": "npm:^1.0.1"
|
||||||
"@swc/core": "npm:^1.4.13"
|
"@swc/core": "npm:^1.4.13"
|
||||||
"@testing-library/react": "npm:^16.0.0"
|
"@testing-library/react": "npm:^16.0.0"
|
||||||
"@toeverything/theme": "npm:^1.0.2"
|
"@toeverything/theme": "npm:^1.0.4"
|
||||||
"@types/animejs": "npm:^3.1.12"
|
"@types/animejs": "npm:^3.1.12"
|
||||||
"@types/bytes": "npm:^3.1.4"
|
"@types/bytes": "npm:^3.1.4"
|
||||||
"@types/image-blob-reduce": "npm:^4.1.4"
|
"@types/image-blob-reduce": "npm:^4.1.4"
|
||||||
@@ -14946,10 +14946,10 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
"@toeverything/theme@npm:^1.0.2":
|
"@toeverything/theme@npm:^1.0.2, @toeverything/theme@npm:^1.0.4":
|
||||||
version: 1.0.3
|
version: 1.0.4
|
||||||
resolution: "@toeverything/theme@npm:1.0.3"
|
resolution: "@toeverything/theme@npm:1.0.4"
|
||||||
checksum: 10/2e1df60e417717aee7525d1ec0432462f95cfaded3da6ad5d7c4b273cd97b77bd3948669f56be06b592e2c6a51cd892902b7b18ba2b71360ccc0a9daba18b315
|
checksum: 10/88d7cb0c2f77e1632c9a25bb6605c8df22b8b41cd7f76435c0d64beb9f4758a56ed48fb35965588c004a97634b8097ea4ebc0ea44747ce46db0e88d9a0527d26
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user