refactor!: next generation AFFiNE code structure (#1176)

This commit is contained in:
Himself65
2023-03-01 01:40:01 -06:00
committed by GitHub
parent 2dcccc772c
commit e0481d29ad
270 changed files with 8308 additions and 6829 deletions

View File

@@ -0,0 +1,104 @@
import { BlockHub } from '@blocksuite/blocks';
import { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import { useEffect, useRef } from 'react';
import { BlockSuiteWorkspace } from '../../../shared';
export type EditorProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
page: Page;
mode: 'page' | 'edgeless';
onInit?: (page: Page, editor: Readonly<EditorContainer>) => void;
onLoad?: (page: Page, editor: EditorContainer) => void;
};
import markdown from '../../../templates/Welcome-to-AFFiNE-Alpha-Downhills.md';
const exampleTitle = markdown
.split('\n')
.splice(0, 1)
.join('')
.replaceAll('#', '')
.trim();
const exampleText = markdown.split('\n').slice(1).join('\n');
const kFirstPage = 'affine-first-page';
export const BlockSuiteEditor = (props: EditorProps) => {
const page = props.page;
const editorRef = useRef<EditorContainer | null>(null);
const blockHubRef = useRef<BlockHub | null>(null);
if (editorRef.current === null) {
editorRef.current = new EditorContainer();
// fixme(himself65): remove `globalThis.editor`
// @ts-expect-error
globalThis.editor = editorRef.current;
}
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (editorRef.current) {
editorRef.current.mode = props.mode;
}
}, [props.mode]);
useEffect(() => {
const editor = editorRef.current;
if (!editor || !ref.current || !page) {
return;
}
editor.page = page;
if (page.root === null) {
if (props.onInit) {
props.onInit(page, editor);
} else {
console.debug('Initializing page with default content');
// Add page block and surface block at root level
const title =
localStorage.getItem(kFirstPage) === null ? exampleTitle : undefined;
const pageBlockId = page.addBlockByFlavour('affine:page', {
title,
});
page.addBlockByFlavour('affine:surface', {}, null);
const frameId = page.addBlockByFlavour('affine:frame', {}, pageBlockId);
page.addBlockByFlavour('affine:paragraph', {}, frameId);
if (localStorage.getItem(kFirstPage) === null) {
// fixme(himself65): remove
editor.clipboard.importMarkdown(exampleText, frameId);
props.blockSuiteWorkspace.setPageMeta(page.id, { title });
localStorage.setItem(kFirstPage, 'true');
}
page.resetHistory();
}
}
props.onLoad?.(page, editor);
return;
}, [page, props]);
useEffect(() => {
const editor = editorRef.current;
const container = ref.current;
if (!editor || !container || !page) {
return;
}
editor.createBlockHub().then(blockHub => {
if (blockHubRef.current) {
blockHubRef.current.remove();
}
blockHubRef.current = blockHub;
const toolWrapper = document.querySelector('#toolWrapper');
assertExists(toolWrapper);
toolWrapper.appendChild(blockHub);
});
container.appendChild(editor);
return () => {
blockHubRef.current?.remove();
container.removeChild(editor);
};
}, [page]);
return <div className="editor-wrapper" ref={ref} />;
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { BlockSuiteWorkspace } from '../../../shared';
import PageList from './page-list';
export type BlockSuitePageListProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onOpenPage: (pageId: string, newTab?: boolean) => void;
};
export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
blockSuiteWorkspace,
onOpenPage,
}) => {
return (
<PageList
blockSuiteWorkspace={blockSuiteWorkspace}
onClickPage={onOpenPage}
listType="all"
/>
);
};
export const BlockSuitePublicPageList: React.FC<BlockSuitePageListProps> = ({
blockSuiteWorkspace,
onOpenPage,
}) => {
return (
<PageList
isPublic={true}
blockSuiteWorkspace={blockSuiteWorkspace}
onClickPage={onOpenPage}
listType="all"
/>
);
};

View File

@@ -0,0 +1,27 @@
import { TableCell, TableCellProps } from '@affine/component';
import { PageMeta } from '@blocksuite/store';
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import React from 'react';
dayjs.extend(localizedFormat);
export const DateCell = ({
pageMeta,
dateKey,
backupKey = '',
...props
}: {
pageMeta: PageMeta;
dateKey: keyof PageMeta;
backupKey?: keyof PageMeta;
} & Omit<TableCellProps, 'children'>) => {
const value = pageMeta[dateKey] ?? pageMeta[backupKey];
return (
<TableCell ellipsis={true} {...props}>
{value ? dayjs(value as string).format('YYYY-MM-DD HH:mm') : '--'}
</TableCell>
);
};
export default DateCell;

View File

@@ -0,0 +1,27 @@
import { Empty } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import React from 'react';
export const PageListEmpty = (props: { listType?: string }) => {
const { listType } = props;
const { t } = useTranslation();
const getEmptyDescription = () => {
if (listType === 'all') {
return t('emptyAllPages');
}
if (listType === 'favorite') {
return t('emptyFavorite');
}
if (listType === 'trash') {
return t('emptyTrash');
}
};
return (
<div style={{ height: 'calc(100% - 60px)' }}>
<Empty description={getEmptyDescription()} />
</div>
);
};
export default PageListEmpty;

View File

@@ -0,0 +1,171 @@
import {
Confirm,
FlexWrapper,
IconButton,
Menu,
MenuItem,
Tooltip,
} from '@affine/component';
import { toast } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
DeletePermanentlyIcon,
DeleteTemporarilyIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons';
import { PageMeta } from '@blocksuite/store';
import React, { useState } from 'react';
export type OperationCellProps = {
pageMeta: PageMeta;
onOpenPageInNewTab: (pageId: string) => void;
onToggleFavoritePage: (pageId: string) => void;
onToggleTrashPage: (pageId: string) => void;
};
export const OperationCell: React.FC<OperationCellProps> = ({
pageMeta,
onOpenPageInNewTab,
onToggleFavoritePage,
onToggleTrashPage,
}) => {
const { id, favorite } = pageMeta;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const OperationMenu = (
<>
<MenuItem
onClick={() => {
onToggleFavoritePage(id);
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
icon={favorite ? <FavoritedIcon /> : <FavoriteIcon />}
>
{favorite ? t('Remove from favorites') : t('Add to Favorites')}
</MenuItem>
<MenuItem
onClick={() => {
onOpenPageInNewTab(id);
}}
icon={<OpenInNewIcon />}
>
{t('Open in new tab')}
</MenuItem>
<MenuItem
onClick={() => {
setOpen(true);
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Delete')}
</MenuItem>
</>
);
return (
<>
<FlexWrapper alignItems="center" justifyContent="center">
<Menu
content={OperationMenu}
placement="bottom-end"
disablePortal={true}
trigger="click"
>
<IconButton darker={true}>
<MoreVerticalIcon />
</IconButton>
</Menu>
</FlexWrapper>
<Confirm
open={open}
title={t('Delete page?')}
content={t('will be permanently deleted', {
title: pageMeta.title || 'Untitled',
})}
confirmText={t('Delete')}
confirmType="danger"
onConfirm={() => {
onToggleTrashPage(id);
toast(t('Deleted'));
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
/>
</>
);
};
export type TrashOperationCellProps = {
pageMeta: PageMeta;
onPermanentlyDeletePage: (pageId: string) => void;
onRestorePage: (pageId: string) => void;
onOpenPage: (pageId: string) => void;
};
export const TrashOperationCell: React.FC<TrashOperationCellProps> = ({
pageMeta,
onPermanentlyDeletePage,
onRestorePage,
onOpenPage,
}) => {
const { id, title } = pageMeta;
// const { openPage, getPageMeta } = usePageHelper();
// const { toggleDeletePage, permanentlyDeletePage } = usePageHelper();
// const confirm = useConfirm(store => store.confirm);
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<FlexWrapper>
<Tooltip content={t('Restore it')} placement="top-start">
<IconButton
darker={true}
style={{ marginRight: '12px' }}
onClick={() => {
onRestorePage(id);
toast(t('restored', { title: title || 'Untitled' }));
onOpenPage(id);
}}
>
<ResetIcon />
</IconButton>
</Tooltip>
<Tooltip content={t('Delete permanently')} placement="top-start">
<IconButton
darker={true}
onClick={() => {
setOpen(true);
}}
>
<DeletePermanentlyIcon />
</IconButton>
</Tooltip>
<Confirm
title={t('Delete permanently?')}
content={t("Once deleted, you can't undo this action.")}
confirmText={t('Delete')}
confirmType="danger"
open={open}
onConfirm={() => {
onPermanentlyDeletePage(id);
toast(t('Permanently deleted'));
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
/>
</FlexWrapper>
);
};

View File

@@ -0,0 +1,216 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@affine/component';
import { Content, IconButton, toast, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
EdgelessIcon,
FavoritedIcon,
FavoriteIcon,
PaperIcon,
} from '@blocksuite/icons';
import { PageMeta } from '@blocksuite/store';
import { useMediaQuery, useTheme as useMuiTheme } from '@mui/material';
import React, { useMemo } from 'react';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
import { useTheme } from '../../../../providers/ThemeProvider';
import { BlockSuiteWorkspace } from '../../../../shared';
import DateCell from './DateCell';
import Empty from './Empty';
import { OperationCell, TrashOperationCell } from './OperationCell';
import {
StyledTableContainer,
StyledTableRow,
StyledTitleLink,
StyledTitleWrapper,
} from './styles';
const FavoriteTag = ({
pageMeta: { favorite, id },
}: {
pageMeta: PageMeta;
}) => {
const { theme } = useTheme();
const { t } = useTranslation();
return (
<Tooltip
content={favorite ? t('Favorited') : t('Favorite')}
placement="top-start"
>
<IconButton
darker={true}
iconSize={[20, 20]}
onClick={e => {
e.stopPropagation();
// toggleFavoritePage(id);
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
style={{
color: favorite ? theme.colors.primaryColor : theme.colors.iconColor,
}}
className={favorite ? '' : 'favorite-button'}
>
{favorite ? (
<FavoritedIcon data-testid="favorited-icon" />
) : (
<FavoriteIcon />
)}
</IconButton>
</Tooltip>
);
};
type PageListProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
isPublic?: boolean;
listType?: 'all' | 'trash' | 'favorite';
onClickPage: (pageId: string, newTab?: boolean) => void;
};
const filter = {
all: (pageMeta: PageMeta) => !pageMeta.trash,
trash: (pageMeta: PageMeta) => pageMeta.trash,
favorite: (pageMeta: PageMeta) => pageMeta.favorite,
};
export const PageList: React.FC<PageListProps> = ({
blockSuiteWorkspace,
isPublic = false,
listType,
onClickPage,
}) => {
const pageList = usePageMeta(blockSuiteWorkspace);
const helper = usePageMetaHelper(blockSuiteWorkspace);
const { t } = useTranslation();
const theme = useMuiTheme();
const matches = useMediaQuery(theme.breakpoints.up('sm'));
const isTrash = listType === 'trash';
const list = useMemo(
() => pageList.filter(filter[listType ?? 'all']),
[pageList, listType]
);
if (list.length === 0) {
return <Empty listType={listType} />;
}
return (
<StyledTableContainer>
<Table>
<TableHead>
<TableRow>
{matches && (
<>
<TableCell proportion={0.5}>{t('Title')}</TableCell>
<TableCell proportion={0.2}>{t('Created')}</TableCell>
<TableCell proportion={0.2}>
{isTrash ? t('Moved to Trash') : t('Updated')}
</TableCell>
<TableCell proportion={0.1}></TableCell>
</>
)}
</TableRow>
</TableHead>
<TableBody>
{list.map((pageMeta, index) => {
return (
<StyledTableRow
data-testid={`page-list-item-${pageMeta.id}}`}
key={`${pageMeta.id}-${index}`}
>
<TableCell
onClick={() => {
onClickPage(pageMeta.id);
}}
>
<StyledTitleWrapper>
<StyledTitleLink>
{pageMeta.mode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PaperIcon />
)}
<Content ellipsis={true} color="inherit">
{pageMeta.title || t('Untitled')}
</Content>
</StyledTitleLink>
{!isTrash && <FavoriteTag pageMeta={pageMeta} />}
</StyledTitleWrapper>
</TableCell>
{matches && (
<>
<DateCell
pageMeta={pageMeta}
dateKey="createDate"
onClick={() => {
onClickPage(pageMeta.id);
}}
/>
<DateCell
pageMeta={pageMeta}
dateKey={isTrash ? 'trashDate' : 'updatedDate'}
backupKey={isTrash ? 'trashDate' : 'createDate'}
onClick={() => {
onClickPage(pageMeta.id);
}}
/>
{!isPublic && (
<TableCell
style={{ padding: 0 }}
data-testid={`more-actions-${pageMeta.id}`}
>
{isTrash ? (
<TrashOperationCell
pageMeta={pageMeta}
onPermanentlyDeletePage={pageId => {
blockSuiteWorkspace.removePage(pageId);
}}
onRestorePage={() => {
helper.setPageMeta(pageMeta.id, {
trash: false,
});
}}
onOpenPage={pageId => {
onClickPage(pageId, false);
}}
/>
) : (
<OperationCell
pageMeta={pageMeta}
onOpenPageInNewTab={pageId => {
onClickPage(pageId, true);
}}
onToggleFavoritePage={(pageId: string) => {
helper.setPageMeta(pageId, {
favorite: !pageMeta.favorite,
});
}}
onToggleTrashPage={() => {
helper.setPageMeta(pageMeta.id, {
trash: !pageMeta.trash,
});
}}
/>
)}
</TableCell>
)}
</>
)}
</StyledTableRow>
);
})}
</TableBody>
</Table>
</StyledTableContainer>
);
};
export default PageList;

View File

@@ -0,0 +1,51 @@
import { displayFlex, styled } from '@affine/component';
import { TableRow } from '@affine/component';
export const StyledTableContainer = styled.div(() => {
return {
height: 'calc(100vh - 60px)',
padding: '78px 72px',
overflowY: 'auto',
};
});
export const StyledTitleWrapper = styled.div(({ theme }) => {
return {
...displayFlex('flex-start', 'center'),
a: {
color: 'inherit',
},
'a:visited': {
color: 'unset',
},
'a:hover': {
color: theme.colors.primaryColor,
},
};
});
export const StyledTitleLink = styled.div(({ theme }) => {
return {
maxWidth: '80%',
marginRight: '18px',
...displayFlex('flex-start', 'center'),
color: theme.colors.textColor,
'>svg': {
fontSize: '24px',
marginRight: '12px',
color: theme.colors.iconColor,
},
};
});
export const StyledTableRow = styled(TableRow)(() => {
return {
cursor: 'pointer',
'.favorite-button': {
display: 'none',
},
'&:hover': {
'.favorite-button': {
display: 'flex',
},
},
};
});

View File

@@ -0,0 +1,85 @@
import { CSSProperties, DOMAttributes } from 'react';
type IconProps = {
style?: CSSProperties;
} & DOMAttributes<SVGElement>;
export const ArrowIcon = ({
style: propsStyle = {},
direction = 'right',
...props
}: IconProps & { direction?: 'left' | 'right' | 'middle' }) => {
const style = {
transform: `rotate(${direction === 'left' ? '0' : '180deg'})`,
opacity: direction === 'middle' ? 0 : 1,
...propsStyle,
};
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="6"
height="16"
viewBox="0 0 6 16"
fill="currentColor"
{...props}
style={style}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.602933 0.305738C0.986547 0.0865297 1.47523 0.219807 1.69444 0.603421L5.41093 7.10728C5.72715 7.66066 5.72715 8.34 5.41093 8.89338L1.69444 15.3972C1.47523 15.7809 0.986547 15.9141 0.602933 15.6949C0.219319 15.4757 0.0860414 14.987 0.305249 14.6034L4.02174 8.09956C4.05688 8.03807 4.05688 7.96259 4.02174 7.9011L0.305249 1.39724C0.0860414 1.01363 0.219319 0.524946 0.602933 0.305738Z"
/>
</svg>
);
};
export const PaperIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
style={style}
{...props}
>
<path
fillRule="evenodd"
d="M17 9.8H7V8.2h10v1.6ZM12 12.8H7v-1.6h5v1.6Z"
clipRule="evenodd"
/>
<path d="m14 19 7-7h-5a2 2 0 0 0-2 2v5Z" />
<path
fillRule="evenodd"
d="M5 6.6h14c.22 0 .4.18.4.4v6.6L21 12V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h9l1.6-1.6H5a.4.4 0 0 1-.4-.4V7c0-.22.18-.4.4-.4Z"
clipRule="evenodd"
/>
</svg>
);
};
export const EdgelessIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
style={style}
{...props}
>
<path
fillRule="evenodd"
d="M12 17.4a5.4 5.4 0 1 0 0-10.8 5.4 5.4 0 0 0 0 10.8Zm7-5.4a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
clipRule="evenodd"
/>
<path
fillRule="evenodd"
d="M18.565 8a.8.8 0 0 1 .8-.8c.797 0 1.511.07 2.07.24.5.15 1.172.477 1.334 1.202v.004c.089.405-.026.776-.186 1.065a3.165 3.165 0 0 1-.652.782c-.52.471-1.265.947-2.15 1.407-1.783.927-4.28 1.869-7.077 2.62-2.796.752-5.409 1.184-7.381 1.266-.98.04-1.848-.003-2.516-.162-.333-.079-.662-.196-.937-.38-.282-.19-.547-.48-.639-.892v-.002c-.138-.63.202-1.173.518-1.532.343-.39.836-.768 1.413-1.129a.8.8 0 0 1 .848 1.357c-.515.322-.862.605-1.06.83a1.524 1.524 0 0 0-.078.096c.07.03.169.064.304.095.461.11 1.163.158 2.08.12 1.822-.075 4.314-.481 7.033-1.212 2.718-.73 5.1-1.635 6.753-2.494.832-.433 1.441-.835 1.814-1.173.127-.115.213-.21.268-.284a1.67 1.67 0 0 0-.153-.053c-.342-.104-.878-.171-1.606-.171a.8.8 0 0 1-.8-.8Zm2.692 1.097-.004-.004a.026.026 0 0 1 .004.004Zm-18.46 5 .001-.002v.002Z"
clipRule="evenodd"
/>
</svg>
);
};

View File

@@ -0,0 +1,167 @@
import { useTranslation } from '@affine/i18n';
import { assertExists } from '@blocksuite/store';
import React, { cloneElement, CSSProperties, useEffect, useState } from 'react';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
// todo(himself65): remove `useTheme` hook
import { useTheme } from '../../../../providers/ThemeProvider';
import { BlockSuiteWorkspace } from '../../../../shared';
import { EdgelessIcon, PaperIcon } from './Icons';
import {
StyledAnimateRadioContainer,
StyledIcon,
StyledLabel,
StyledMiddleLine,
StyledRadioItem,
} from './style';
import type { AnimateRadioItemProps, RadioItemStatus } from './type';
const PaperItem = ({ active }: { active?: boolean }) => {
const {
theme: {
colors: { iconColor, primaryColor },
},
} = useTheme();
return <PaperIcon style={{ color: active ? primaryColor : iconColor }} />;
};
const EdgelessItem = ({ active }: { active?: boolean }) => {
const {
theme: {
colors: { iconColor, primaryColor },
},
} = useTheme();
return <EdgelessIcon style={{ color: active ? primaryColor : iconColor }} />;
};
const AnimateRadioItem = ({
active,
status,
icon: propsIcon,
label,
isLeft,
...props
}: AnimateRadioItemProps) => {
const icon = (
<StyledIcon shrink={status === 'shrink'} isLeft={isLeft}>
{cloneElement(propsIcon, {
active,
})}
</StyledIcon>
);
return (
<StyledRadioItem title={label} active={active} status={status} {...props}>
{isLeft ? icon : null}
<StyledLabel shrink={status !== 'stretch'} isLeft={isLeft}>
{label}
</StyledLabel>
{isLeft ? null : icon}
</StyledRadioItem>
);
};
export type EditorModeSwitchProps = {
// todo(himself65): combine these two properties
blockSuiteWorkspace: BlockSuiteWorkspace;
pageId: string;
isHover: boolean;
style: CSSProperties;
};
export const EditorModeSwitch: React.FC<EditorModeSwitchProps> = ({
isHover,
style = {},
blockSuiteWorkspace,
pageId,
}) => {
const { mode: themeMode } = useTheme();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const { trash, mode = 'page' } = pageMeta;
const modifyRadioItemStatus = (): RadioItemStatus => {
return {
left: isHover
? mode === 'page'
? 'stretch'
: 'normal'
: mode === 'page'
? 'shrink'
: 'hidden',
right: isHover
? mode === 'edgeless'
? 'stretch'
: 'normal'
: mode === 'edgeless'
? 'shrink'
: 'hidden',
};
};
const [radioItemStatus, setRadioItemStatus] = useState<RadioItemStatus>(
modifyRadioItemStatus
);
useEffect(() => {
setRadioItemStatus(modifyRadioItemStatus());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHover, mode]);
const { t } = useTranslation();
return (
<StyledAnimateRadioContainer
data-testid="editor-mode-switcher"
shrink={!isHover}
style={style}
disabled={!!trash}
>
<AnimateRadioItem
isLeft={true}
label={t('Paper')}
icon={<PaperItem />}
active={mode === 'page'}
status={radioItemStatus.left}
onClick={() => {
setPageMeta(pageId, { mode: 'page' });
}}
onMouseEnter={() => {
setRadioItemStatus({
right: 'normal',
left: 'stretch',
});
}}
onMouseLeave={() => {
setRadioItemStatus(modifyRadioItemStatus());
}}
/>
<StyledMiddleLine hidden={!isHover} dark={themeMode === 'dark'} />
<AnimateRadioItem
isLeft={false}
label={t('Edgeless')}
data-testid="switch-edgeless-item"
icon={<EdgelessItem />}
active={mode === 'edgeless'}
status={radioItemStatus.right}
onClick={() => {
setPageMeta(pageId, { mode: 'edgeless' });
}}
onMouseEnter={() => {
setRadioItemStatus({
left: 'normal',
right: 'stretch',
});
}}
onMouseLeave={() => {
setRadioItemStatus(modifyRadioItemStatus());
}}
/>
</StyledAnimateRadioContainer>
);
};
export default EditorModeSwitch;

View File

@@ -0,0 +1,156 @@
import { displayFlex, keyframes, styled } from '@affine/component';
// @ts-ignore
import spring, { toString } from 'css-spring';
import type { ItemStatus } from './type';
const ANIMATE_DURATION = 500;
export const StyledAnimateRadioContainer = styled('div')<{
shrink: boolean;
disabled: boolean;
}>(({ shrink, theme, disabled }) => {
const animateScaleStretch = keyframes`${toString(
spring({ width: '36px' }, { width: '160px' }, { preset: 'gentle' })
)}`;
const animateScaleShrink = keyframes(
`${toString(
spring({ width: '160px' }, { width: '36px' }, { preset: 'gentle' })
)}`
);
const shrinkStyle = shrink
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
background: 'transparent',
}
: {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
};
return {
height: '36px',
borderRadius: '18px',
background: disabled ? 'transparent' : theme.colors.hoverBackground,
position: 'relative',
display: 'flex',
transition: `background ${ANIMATE_DURATION}ms, border ${ANIMATE_DURATION}ms`,
border: '1px solid transparent',
...(disabled ? { pointerEvents: 'none' } : shrinkStyle),
':hover': {
border: disabled ? '' : `1px solid ${theme.colors.primaryColor}`,
},
};
});
export const StyledMiddleLine = styled('div')<{
hidden: boolean;
dark: boolean;
}>(({ hidden, dark }) => {
return {
width: '1px',
height: '16px',
background: dark ? '#4d4c53' : '#D0D7E3',
top: '0',
bottom: '0',
margin: 'auto',
opacity: hidden ? '0' : '1',
};
});
export const StyledRadioItem = styled('div')<{
status: ItemStatus;
active: boolean;
}>(({ status, active, theme }) => {
const animateScaleStretch = keyframes`${toString(
spring({ width: '44px' }, { width: '112px' })
)}`;
const animateScaleOrigin = keyframes(
`${toString(spring({ width: '112px' }, { width: '44px' }))}`
);
const animateScaleShrink = keyframes(
`${toString(spring({ width: '0px' }, { width: '36px' }))}`
);
const dynamicStyle =
status === 'stretch'
? {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
flexShrink: '0',
}
: status === 'shrink'
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
}
: status === 'normal'
? { animation: `${animateScaleOrigin} ${ANIMATE_DURATION}ms forwards` }
: {};
const {
colors: { iconColor, primaryColor },
} = theme;
return {
width: '0',
height: '100%',
display: 'flex',
cursor: 'pointer',
overflow: 'hidden',
color: active ? primaryColor : iconColor,
...dynamicStyle,
};
});
export const StyledLabel = styled('div')<{
shrink: boolean;
isLeft: boolean;
}>(({ shrink, isLeft }) => {
const animateScaleStretch = keyframes`${toString(
spring(
{ width: '0px' },
{ width: isLeft ? '65px' : '75px' },
{ preset: 'gentle' }
)
)}`;
const animateScaleShrink = keyframes(
`${toString(
spring(
{ width: isLeft ? '65px' : '75px' },
{ width: '0px' },
{ preset: 'gentle' }
)
)}`
);
const shrinkStyle = shrink
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
}
: {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
};
return {
display: 'flex',
alignItems: 'center',
justifyContent: isLeft ? 'flex-start' : 'flex-end',
fontSize: '16px',
flexShrink: '0',
transition: `transform ${ANIMATE_DURATION}ms`,
fontWeight: 'normal',
overflow: 'hidden',
whiteSpace: 'nowrap',
...shrinkStyle,
};
});
export const StyledIcon = styled('div')<{
shrink: boolean;
isLeft: boolean;
}>(({ shrink, isLeft }) => {
const dynamicStyle = shrink
? { width: '36px' }
: { width: isLeft ? '44px' : '34px' };
return {
...displayFlex('center', 'center'),
flexShrink: '0',
...dynamicStyle,
};
});

View File

@@ -0,0 +1,15 @@
import { DOMAttributes, ReactElement } from 'react';
export type ItemStatus = 'normal' | 'stretch' | 'shrink' | 'hidden';
export type RadioItemStatus = {
left: ItemStatus;
right: ItemStatus;
};
export type AnimateRadioItemProps = {
active: boolean;
status: ItemStatus;
label: string;
icon: ReactElement;
isLeft: boolean;
} & DOMAttributes<HTMLDivElement>;

View File

@@ -0,0 +1,119 @@
// fixme(himself65): refactor this file
import { Menu, MenuItem } from '@affine/component';
import { IconButton } from '@affine/component';
import { toast } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
DeleteTemporarilyIcon,
ExportIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
} from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store';
import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
import { EdgelessIcon, PaperIcon } from '../editor-mode-switch/Icons';
const PopoverContent = () => {
const { t } = useTranslation();
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
const [pageId] = useCurrentPageId();
assertExists(workspace);
assertExists(pageId);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const { mode = 'page', favorite, trash } = pageMeta;
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
//
return (
<>
<MenuItem
data-testid="editor-option-menu-favorite"
onClick={() => {
setPageMeta(pageId, { favorite: !favorite });
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
icon={favorite ? <FavoritedIcon /> : <FavoriteIcon />}
>
{favorite ? t('Remove from favorites') : t('Add to Favorites')}
</MenuItem>
<MenuItem
icon={mode === 'page' ? <EdgelessIcon /> : <PaperIcon />}
data-testid="editor-option-menu-edgeless"
onClick={() => {
setPageMeta(pageId, {
mode: mode === 'page' ? 'edgeless' : 'page',
});
}}
>
{t('Convert to ')}
{mode === 'page' ? t('Edgeless') : t('Page')}
</MenuItem>
<Menu
placement="left-start"
content={
<>
<MenuItem
onClick={() => {
// @ts-expect-error
globalThis.editor.contentParser.onExportHtml();
}}
icon={<ExportToHtmlIcon />}
>
{t('Export to HTML')}
</MenuItem>
<MenuItem
onClick={() => {
// @ts-expect-error
globalThis.editor.contentParser.onExportMarkdown();
}}
icon={<ExportToMarkdownIcon />}
>
{t('Export to Markdown')}
</MenuItem>
</>
}
>
<MenuItem icon={<ExportIcon />} isDir={true}>
{t('Export')}
</MenuItem>
</Menu>
<MenuItem
data-testid="editor-option-menu-delete"
onClick={() => {
// fixme(himself65): regression that don't have conform dialog
setPageMeta(pageId, { trash: !trash });
toast(t('Moved to Trash'));
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Delete')}
</MenuItem>
</>
);
};
export const EditorOptionMenu = () => {
return (
<Menu content={<PopoverContent />} placement="bottom-end" trigger="click">
<IconButton data-testid="editor-option-menu">
<MoreVerticalIcon />
</IconButton>
</Menu>
);
};

View File

@@ -0,0 +1,163 @@
import { displayFlex, IconButton, styled, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { assertEquals, assertExists } from '@blocksuite/store';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { lockMutex } from '../../../../atoms';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { transformWorkspace } from '../../../../plugins';
import {
AffineOfficialWorkspace,
LocalWorkspace,
RemWorkspaceFlavour,
} from '../../../../shared';
import { TransformWorkspaceToAffineModal } from '../../../affine/transform-workspace-to-affine-modal';
import { LocalWorkspaceIcon } from '../../../pure/icons';
const NoNetWorkIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.46968 4.46967C4.76257 4.17678 5.23745 4.17678 5.53034 4.46967L19.5303 18.4697C19.8232 18.7626 19.8232 19.2374 19.5303 19.5303C19.2374 19.8232 18.7626 19.8232 18.4697 19.5303L15.1357 16.1963C14.8665 16.3041 14.5474 16.2497 14.3285 16.0325C13.7395 15.4484 12.9163 15.0833 12 15.0833C11.0837 15.0833 10.2606 15.4484 9.67156 16.0325C9.37746 16.3242 8.90259 16.3222 8.61091 16.0281C8.31924 15.734 8.32121 15.2592 8.61531 14.9675C9.47826 14.1117 10.6784 13.5833 12 13.5833C12.1873 13.5833 12.3721 13.5939 12.5539 13.6146L10.3227 11.3833C9.01431 11.6836 7.84393 12.3277 6.91745 13.2107C6.61761 13.4965 6.14287 13.4851 5.8571 13.1852C5.57132 12.8854 5.58273 12.4106 5.88257 12.1249C6.78938 11.2606 7.88809 10.5873 9.11007 10.1707L7.25873 8.31938C6.2455 8.77701 5.31433 9.37678 4.49234 10.092C4.17986 10.3639 3.70613 10.3311 3.43422 10.0186C3.16232 9.7061 3.19521 9.23237 3.50768 8.96047C4.30046 8.27062 5.18228 7.676 6.13421 7.19486L4.46968 5.53033C4.17679 5.23744 4.17679 4.76256 4.46968 4.46967ZM12 7.30556C11.6586 7.30556 11.321 7.32029 10.9877 7.34911C10.575 7.3848 10.2115 7.07919 10.1759 6.66651C10.1402 6.25384 10.4458 5.89037 10.8585 5.85469C11.2347 5.82216 11.6154 5.80556 12 5.80556C15.2588 5.80556 18.2362 6.99725 20.4923 8.96047C20.8048 9.23237 20.8377 9.7061 20.5658 10.0186C20.2939 10.3311 19.8202 10.3639 19.5077 10.092C17.5178 8.3605 14.888 7.30556 12 7.30556ZM15.2321 11.0675C15.4296 10.7034 15.8849 10.5683 16.249 10.7657C16.9333 11.1368 17.5614 11.5949 18.1175 12.1249C18.4173 12.4106 18.4287 12.8854 18.1429 13.1852C17.8571 13.4851 17.3824 13.4965 17.0826 13.2107C16.6223 12.772 16.1017 12.3922 15.5339 12.0843C15.1698 11.8868 15.0347 11.4316 15.2321 11.0675ZM11.25 17.8333C11.25 17.4191 11.5858 17.0833 12 17.0833H12.008C12.4222 17.0833 12.758 17.4191 12.758 17.8333C12.758 18.2475 12.4222 18.5833 12.008 18.5833H12C11.5858 18.5833 11.25 18.2475 11.25 17.8333Z"
/>
</svg>
);
};
const IconWrapper = styled.div(() => {
return {
width: '32px',
height: '32px',
marginRight: '12px',
fontSize: '22px',
...displayFlex('center', 'center'),
};
});
const getStatus = (workspace: AffineOfficialWorkspace) => {
if (!navigator.onLine) {
return 'offline';
}
if (workspace.flavour === 'local') {
return 'local';
}
return 'cloud';
};
export const SyncUser = () => {
//#region fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
assertExists(workspace);
const router = useRouter();
const [status, setStatus] = useState<'offline' | 'local' | 'cloud'>(
getStatus(workspace)
);
const [prevWorkspace, setPrevWorkspace] = useState(workspace);
if (prevWorkspace !== workspace) {
setPrevWorkspace(workspace);
setStatus(getStatus(workspace));
}
useEffect(() => {
const online = () => {
setStatus(getStatus(workspace));
};
const offline = () => {
setStatus('offline');
};
window.addEventListener('online', online);
window.addEventListener('offline', offline);
return () => {
window.removeEventListener('online', online);
window.removeEventListener('offline', offline);
};
}, [workspace]);
//#endregion
const [open, setOpen] = useState(false);
const { t } = useTranslation();
if (status === 'offline') {
return (
<Tooltip
content={t('Please make sure you are online')}
placement="bottom-end"
>
<IconWrapper>
<NoNetWorkIcon />
</IconWrapper>
</Tooltip>
);
}
if (status === 'local') {
return (
<>
<Tooltip
content={t('Saved then enable AFFiNE Cloud')}
placement="bottom-end"
>
<IconButton
onClick={() => {
setOpen(true);
}}
style={{ marginRight: '12px' }}
>
<LocalWorkspaceIcon />
</IconButton>
</Tooltip>
<TransformWorkspaceToAffineModal
open={open}
onClose={() => {
setOpen(false);
}}
onConform={() => {
// todo(himself65): move this function out of affine component
lockMutex(async () => {
assertEquals(workspace.flavour, RemWorkspaceFlavour.LOCAL);
const id = await transformWorkspace(
RemWorkspaceFlavour.LOCAL,
RemWorkspaceFlavour.AFFINE,
workspace as LocalWorkspace
);
// fixme(himself65): refactor this
router
.replace({
pathname: `/workspace/[workspaceId]/all`,
query: {
workspaceId: id,
},
})
.then(() => {
router.reload();
});
setOpen(false);
});
}}
/>
</>
);
}
return (
<Tooltip content={t('AFFiNE Cloud')} placement="bottom-end">
<IconWrapper>
<CloudWorkspaceIcon />
</IconWrapper>
</Tooltip>
);
};
export default SyncUser;

View File

@@ -0,0 +1,83 @@
import { Button, Confirm } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { assertExists } from '@blocksuite/store';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
export const TrashButtonGroup = () => {
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
const [pageId] = useCurrentPageId();
assertExists(workspace);
assertExists(pageId);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const router = useRouter();
//
const [open, setOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button
bold={true}
shape="round"
style={{ marginRight: '24px' }}
onClick={() => {
setPageMeta(pageId, { trash: false });
}}
>
{t('Restore it')}
</Button>
<Button
bold={true}
shape="round"
type="danger"
onClick={() => {
setOpen(true);
}}
>
{t('Delete permanently')}
</Button>
<Confirm
title={t('TrashButtonGroupTitle')}
content={t('TrashButtonGroupDescription')}
confirmText={t('Delete')}
confirmType="danger"
open={open}
onConfirm={() => {
// fixme(himself65): remove these hooks ASAP
router
.push({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId: workspace.id,
},
})
.then(() => {
blockSuiteWorkspace.removePage(pageId);
});
}}
onCancel={() => {
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
/>
</>
);
};
export default TrashButtonGroup;

View File

@@ -0,0 +1,44 @@
import type { CSSProperties, DOMAttributes } from 'react';
type IconProps = {
style?: CSSProperties;
} & DOMAttributes<SVGElement>;
export const MoonIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style={style}
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.54893 3.31407C9.33328 3.08158 9.27962 2.74521 9.41255 2.45912C9.54547 2.17302 9.83936 1.99233 10.1595 1.99986C13.4456 2.07712 16.5114 4.08044 17.7359 7.29071C19.3437 11.5057 17.1672 16.2024 12.8744 17.781C9.60251 18.9843 6.04745 18.0285 3.82974 15.6428C3.61375 15.4104 3.55978 15.0739 3.69257 14.7876C3.82537 14.5014 4.11931 14.3205 4.43962 14.3279C5.27228 14.3474 6.12412 14.2171 6.94979 13.9135C10.415 12.6391 12.172 8.84782 10.8741 5.44537C10.5657 4.63692 10.1061 3.91474 9.54893 3.31407Z"
/>
</svg>
);
};
export const SunIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
style={style}
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.8002 2.5002C10.8002 2.05837 10.442 1.7002 10.0002 1.7002C9.55837 1.7002 9.2002 2.05837 9.2002 2.5002V3.33353C9.2002 3.77536 9.55837 4.13353 10.0002 4.13353C10.442 4.13353 10.8002 3.77536 10.8002 3.33353V2.5002ZM5.14921 4.01784C4.83679 3.70542 4.33026 3.70542 4.01784 4.01784C3.70542 4.33026 3.70542 4.83679 4.01784 5.14921L4.69627 5.82764C5.00869 6.14006 5.51522 6.14006 5.82764 5.82764C6.14006 5.51522 6.14006 5.00869 5.82764 4.69627L5.14921 4.01784ZM15.9825 5.1492C16.2949 4.83678 16.2949 4.33025 15.9825 4.01783C15.6701 3.70542 15.1636 3.70543 14.8511 4.01785L14.1727 4.69628C13.8603 5.00871 13.8603 5.51524 14.1728 5.82765C14.4852 6.14007 14.9917 6.14006 15.3041 5.82763L15.9825 5.1492ZM10.0002 5.86686C7.71742 5.86686 5.86686 7.71742 5.86686 10.0002C5.86686 12.283 7.71742 14.1335 10.0002 14.1335C12.283 14.1335 14.1335 12.283 14.1335 10.0002C14.1335 7.71742 12.283 5.86686 10.0002 5.86686ZM2.5002 9.2002C2.05837 9.2002 1.7002 9.55837 1.7002 10.0002C1.7002 10.442 2.05837 10.8002 2.5002 10.8002H3.33353C3.77536 10.8002 4.13353 10.442 4.13353 10.0002C4.13353 9.55837 3.77536 9.2002 3.33353 9.2002H2.5002ZM16.6669 9.2002C16.225 9.2002 15.8669 9.55837 15.8669 10.0002C15.8669 10.442 16.225 10.8002 16.6669 10.8002H17.5002C17.942 10.8002 18.3002 10.442 18.3002 10.0002C18.3002 9.55837 17.942 9.2002 17.5002 9.2002H16.6669ZM5.82623 15.309C6.13943 14.9973 6.14069 14.4908 5.82906 14.1776C5.51742 13.8644 5.01089 13.8631 4.69769 14.1748L4.01926 14.8498C3.70606 15.1615 3.70479 15.668 4.01643 15.9812C4.32807 16.2944 4.8346 16.2956 5.1478 15.984L5.82623 15.309ZM15.3027 14.1748C14.9895 13.8631 14.483 13.8644 14.1713 14.1776C13.8597 14.4908 13.861 14.9973 14.1742 15.3089L14.8526 15.984C15.1658 16.2956 15.6723 16.2944 15.9839 15.9812C16.2956 15.668 16.2943 15.1615 15.9811 14.8498L15.3027 14.1748ZM10.8002 16.6669C10.8002 16.225 10.442 15.8669 10.0002 15.8669C9.55837 15.8669 9.2002 16.225 9.2002 16.6669V17.5002C9.2002 17.942 9.55837 18.3002 10.0002 18.3002C10.442 18.3002 10.8002 17.942 10.8002 17.5002V16.6669Z"
/>
</svg>
);
};

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
import { useTheme } from '../../../../../providers/ThemeProvider';
import { MoonIcon, SunIcon } from './Icons';
import { StyledSwitchItem, StyledThemeModeSwitch } from './style';
export const ThemeModeSwitch = () => {
const { mode, changeMode } = useTheme();
const [isHover, setIsHover] = useState(false);
const [firstTrigger, setFirstTrigger] = useState(false);
return (
<StyledThemeModeSwitch
data-testid="change-theme-container"
onMouseEnter={() => {
setIsHover(true);
if (!firstTrigger) {
setFirstTrigger(true);
}
}}
onMouseLeave={() => {
setIsHover(false);
}}
>
<StyledSwitchItem
data-testid="change-theme-light"
active={mode === 'light'}
isHover={isHover}
firstTrigger={firstTrigger}
onClick={() => {
changeMode('light');
}}
>
<SunIcon />
</StyledSwitchItem>
<StyledSwitchItem
data-testid="change-theme-dark"
active={mode === 'dark'}
isHover={isHover}
firstTrigger={firstTrigger}
onClick={() => {
changeMode('dark');
}}
>
<MoonIcon />
</StyledSwitchItem>
</StyledThemeModeSwitch>
);
};
export default ThemeModeSwitch;

View File

@@ -0,0 +1,69 @@
import { displayFlex, keyframes, styled } from '@affine/component';
// @ts-ignore
import spring, { toString } from 'css-spring';
import { CSSProperties } from 'react';
const ANIMATE_DURATION = 400;
export const StyledThemeModeSwitch = styled('div')({
width: '32px',
height: '32px',
borderRadius: '6px',
overflow: 'hidden',
backgroundColor: 'transparent',
position: 'relative',
});
export const StyledSwitchItem = styled('div')<{
active: boolean;
isHover: boolean;
firstTrigger: boolean;
}>(({ active, isHover, firstTrigger, theme }) => {
const activeRaiseAnimate = keyframes`${toString(
spring({ top: '0' }, { top: '-100%' }, { preset: 'gentle' })
)}`;
const raiseAnimate = keyframes`${toString(
spring({ top: '100%' }, { top: '0' }, { preset: 'gentle' })
)}`;
const activeDeclineAnimate = keyframes`${toString(
spring({ top: '-100%' }, { top: '0' }, { preset: 'gentle' })
)}`;
const declineAnimate = keyframes`${toString(
spring({ top: '0' }, { top: '100%' }, { preset: 'gentle' })
)}`;
const activeStyle = active
? {
color: theme.colors.iconColor,
top: '0',
animation: firstTrigger
? `${
isHover ? activeRaiseAnimate : activeDeclineAnimate
} ${ANIMATE_DURATION}ms forwards`
: 'unset',
animationDirection: isHover ? 'normal' : 'alternate',
}
: ({
top: '100%',
color: theme.colors.primaryColor,
backgroundColor: theme.colors.hoverBackground,
animation: firstTrigger
? `${
isHover ? raiseAnimate : declineAnimate
} ${ANIMATE_DURATION}ms forwards`
: 'unset',
animationDirection: isHover ? 'normal' : 'alternate',
} as CSSProperties);
return {
width: '32px',
height: '32px',
position: 'absolute',
left: '0',
...displayFlex('center', 'center'),
cursor: 'pointer',
...activeStyle,
svg: {
width: '24px',
height: '24px',
},
};
});

View File

@@ -0,0 +1,88 @@
import { CloseIcon } from '@blocksuite/icons';
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import { EditorOptionMenu } from './header-right-items/EditorOptionMenu';
import SyncUser from './header-right-items/SyncUser';
import ThemeModeSwitch from './header-right-items/theme-mode-switch';
import TrashButtonGroup from './header-right-items/TrashButtonGroup';
import {
StyledBrowserWarning,
StyledCloseButton,
StyledHeader,
StyledHeaderContainer,
StyledHeaderRightSide,
} from './styles';
import { OSWarningMessage, shouldShowWarning } from './utils';
const BrowserWarning = ({
show,
onClose,
}: {
show: boolean;
onClose: () => void;
}) => {
return (
<StyledBrowserWarning show={show}>
<OSWarningMessage />
<StyledCloseButton onClick={onClose}>
<CloseIcon />
</StyledCloseButton>
</StyledBrowserWarning>
);
};
type HeaderRightItemNames =
| 'editorOptionMenu'
| 'trashButtonGroup'
| 'themeModeSwitch'
| 'syncUser';
const HeaderRightItems: Record<HeaderRightItemNames, React.FC> = {
editorOptionMenu: EditorOptionMenu,
trashButtonGroup: TrashButtonGroup,
themeModeSwitch: ThemeModeSwitch,
syncUser: SyncUser,
};
export type HeaderProps = PropsWithChildren<{
rightItems?: HeaderRightItemNames[];
}>;
export const Header: React.FC<HeaderProps> = ({
rightItems = ['syncUser', 'themeModeSwitch'],
children,
}) => {
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
setShowWarning(shouldShowWarning());
}, []);
return (
<StyledHeaderContainer hasWarning={showWarning}>
<BrowserWarning
show={showWarning}
onClose={() => {
setShowWarning(false);
}}
/>
<StyledHeader
hasWarning={showWarning}
data-testid="editor-header-items"
data-tauri-drag-region
>
{children}
<StyledHeaderRightSide>
{useMemo(
() =>
rightItems.map(itemName => {
const Item = HeaderRightItems[itemName];
return <Item key={itemName} />;
}),
[rightItems]
)}
</StyledHeaderRightSide>
</StyledHeader>
</StyledHeaderContainer>
);
};
export default Header;

View File

@@ -0,0 +1,90 @@
import { Content } from '@affine/component';
import { assertExists } from '@blocksuite/store';
import { useSetAtom } from 'jotai';
import React, { useState } from 'react';
import { openQuickSearchModalAtom } from '../../../atoms';
import { usePageMeta } from '../../../hooks/use-page-meta';
import { BlockSuiteWorkspace } from '../../../shared';
import { PageNotFoundError } from '../../affine/affine-error-eoundary';
import { EditorModeSwitch } from './editor-mode-switch';
import Header from './header';
import { QuickSearchButton } from './quick-search-button';
import {
StyledSearchArrowWrapper,
StyledSwitchWrapper,
StyledTitle,
StyledTitleWrapper,
} from './styles';
export type BlockSuiteEditorHeaderProps = React.PropsWithChildren<{
blockSuiteWorkspace: BlockSuiteWorkspace;
pageId: string;
}>;
export const BlockSuiteEditorHeader: React.FC<BlockSuiteEditorHeaderProps> = ({
blockSuiteWorkspace,
pageId,
children,
}) => {
const page = blockSuiteWorkspace.getPage(pageId);
// fixme(himself65): remove this atom and move it to props
const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom);
if (!page) {
throw new PageNotFoundError(blockSuiteWorkspace, pageId);
}
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const title = pageMeta.title;
const [isHover, setIsHover] = useState(false);
const { trash: isTrash } = pageMeta;
return (
<Header
rightItems={
isTrash
? ['trashButtonGroup']
: ['syncUser', 'themeModeSwitch', 'editorOptionMenu']
}
>
{children}
{title && (
<StyledTitle
data-tauri-drag-region
onMouseEnter={() => {
if (isTrash) return;
setIsHover(true);
}}
onMouseLeave={() => {
if (isTrash) return;
setIsHover(false);
}}
>
<StyledTitleWrapper>
<StyledSwitchWrapper>
<EditorModeSwitch
blockSuiteWorkspace={blockSuiteWorkspace}
pageId={pageId}
isHover={isHover}
style={{
marginRight: '12px',
}}
/>
</StyledSwitchWrapper>
<Content ellipsis={true}>{title}</Content>
<StyledSearchArrowWrapper>
<QuickSearchButton
onClick={() => {
setOpenQuickSearch(true);
}}
/>
</StyledSearchArrowWrapper>
</StyledTitleWrapper>
</StyledTitle>
)}
</Header>
);
};

View File

@@ -0,0 +1,38 @@
import { IconButton, IconButtonProps } from '@affine/component';
import { styled } from '@affine/component';
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import React from 'react';
const StyledIconButtonWithAnimate = styled(IconButton)(({ theme }) => {
return {
svg: {
transition: 'transform 0.15s ease-in-out',
},
':hover': {
svg: {
transform: 'translateY(3px)',
},
'::after': {
background: theme.colors.pageBackground,
},
},
};
});
// fixme(himself65): need to refactor
export const QuickSearchButton = ({
onClick,
...props
}: Omit<IconButtonProps, 'children'>) => {
return (
<StyledIconButtonWithAnimate
data-testid="header-quickSearchButton"
{...props}
onClick={e => {
onClick?.(e);
}}
>
<ArrowDownSmallIcon />
</StyledIconButtonWithAnimate>
);
};

View File

@@ -0,0 +1,117 @@
import { displayFlex, styled } from '@affine/component';
export const StyledHeaderContainer = styled.div<{ hasWarning: boolean }>(
({ hasWarning }) => {
return {
height: hasWarning ? '96px' : '60px',
padding: '0 28px',
};
}
);
export const StyledHeader = styled.div<{ hasWarning: boolean }>(({ theme }) => {
return {
height: '60px',
width: '100%',
...displayFlex('flex-end', 'center'),
background: theme.colors.pageBackground,
transition: 'background-color 0.5s',
zIndex: 99,
};
});
export const StyledTitle = styled('div')(({ theme }) => ({
width: '720px',
height: '100%',
margin: 'auto',
...displayFlex('center', 'center'),
fontSize: theme.font.base,
}));
export const StyledTitleWrapper = styled('div')({
maxWidth: '720px',
height: '100%',
position: 'relative',
...displayFlex('center', 'center'),
});
export const StyledHeaderRightSide = styled('div')({
height: '100%',
display: 'flex',
alignItems: 'center',
'>*:not(:last-child)': {
marginRight: '12px',
},
});
export const StyledBrowserWarning = styled.div<{ show: boolean }>(
({ theme, show }) => {
return {
backgroundColor: theme.colors.warningBackground,
color: theme.colors.warningColor,
height: '36px',
width: '100vw',
fontSize: theme.font.sm,
position: 'fixed',
left: '0',
top: '0',
display: show ? 'flex' : 'none',
justifyContent: 'center',
alignItems: 'center',
};
}
);
export const StyledCloseButton = styled.div(({ theme }) => {
return {
width: '36px',
height: '36px',
color: theme.colors.iconColor,
cursor: 'pointer',
...displayFlex('center', 'center'),
position: 'absolute',
right: '15px',
top: '0',
svg: {
width: '15px',
height: '15px',
position: 'relative',
zIndex: 1,
},
};
});
export const StyledSwitchWrapper = styled.div(() => {
return {
position: 'absolute',
right: '100%',
top: 0,
bottom: 0,
margin: 'auto',
...displayFlex('center', 'center'),
};
});
export const StyledSearchArrowWrapper = styled.div(() => {
return {
position: 'absolute',
left: 'calc(100% + 4px)',
top: 0,
bottom: 0,
margin: 'auto',
...displayFlex('center', 'center'),
};
});
export const StyledPageListTittleWrapper = styled(StyledTitle)(({ theme }) => {
return {
fontSize: theme.font.base,
color: theme.colors.textColor,
'>svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});

View File

@@ -0,0 +1,47 @@
import { Trans, useTranslation } from '@affine/i18n';
import React, { useEffect, useState } from 'react';
import { getIsMobile } from '../../../utils/get-is-mobile';
// Inspire by https://stackoverflow.com/a/4900484/8415727
const getChromeVersion = () => {
const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
return raw ? parseInt(raw[2], 10) : false;
};
const getIsChrome = () => {
return (
/Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor)
);
};
const minimumChromeVersion = 102;
export const shouldShowWarning = () => {
return (
!window.CLIENT_APP &&
!getIsMobile() &&
(!getIsChrome() || getChromeVersion() < minimumChromeVersion)
);
};
export const OSWarningMessage: React.FC = () => {
const { t } = useTranslation();
const [notChrome, setNotChrome] = useState(false);
const [notGoodVersion, setNotGoodVersion] = useState(false);
useEffect(() => {
setNotChrome(getIsChrome());
setNotGoodVersion(getChromeVersion() < minimumChromeVersion);
}, []);
if (notChrome) {
return (
<span>
<Trans i18nKey="recommendBrowser">
We recommend the <strong>Chrome</strong> browser for optimal
experience.
</Trans>
</span>
);
} else if (notGoodVersion) {
return <span>{t('upgradeBrowser')}</span>;
}
return null;
};