feat: support pivots menu (#1755)

This commit is contained in:
Qi 2023-03-30 17:37:41 +08:00 committed by GitHub
parent 4dd1490eef
commit b6ded30770
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1513 additions and 665 deletions

View File

@ -0,0 +1,24 @@
import { MenuItem } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CopyIcon } from '@blocksuite/icons';
// import { useRouter } from "next/router";
// import { useCallback } from "react";
//
// import { toast } from "../../../utils";
export const CopyLink = () => {
const { t } = useTranslation();
// const router = useRouter();
// const copyUrl = useCallback(() => {
// const workspaceId = router.query.workspaceId;
// navigator.clipboard.writeText(window.location.href);
// toast(t("Copied link to clipboard"));
// }, [router.query.workspaceId, t]);
return (
<>
<MenuItem onClick={() => {}} icon={<CopyIcon />} disabled={true}>
{t('Copy Link')}
</MenuItem>
</>
);
};

View File

@ -0,0 +1,50 @@
import { Menu, MenuItem } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
ArrowRightSmallIcon,
ExportIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
} from '@blocksuite/icons';
export const Export = () => {
const { t } = useTranslation();
return (
<Menu
width={248}
placement="left-start"
trigger="click"
content={
<>
<MenuItem
onClick={() => {
// @ts-expect-error
globalThis.currentEditor.contentParser.onExportHtml();
}}
icon={<ExportToHtmlIcon />}
>
{t('Export to HTML')}
</MenuItem>
<MenuItem
onClick={() => {
// @ts-expect-error
globalThis.currentEditor.contentParser.onExportMarkdown();
}}
icon={<ExportToMarkdownIcon />}
>
{t('Export to Markdown')}
</MenuItem>
</>
}
>
<MenuItem
icon={<ExportIcon />}
endIcon={<ArrowRightSmallIcon />}
onClick={e => e.stopPropagation()}
>
{t('Export')}
</MenuItem>
</Menu>
);
};

View File

@ -0,0 +1,46 @@
import { MenuItem } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ArrowRightSmallIcon, MoveToIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useRef, useState } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
import { PivotsMenu } from '../pivots';
export const MoveTo = ({
metas,
currentMeta,
blockSuiteWorkspace,
}: {
metas: PageMeta[];
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
}) => {
const { t } = useTranslation();
const ref = useRef<HTMLButtonElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = anchorEl !== null;
return (
<>
<MenuItem
ref={ref}
onClick={e => {
e.stopPropagation();
setAnchorEl(ref.current);
}}
icon={<MoveToIcon />}
endIcon={<ArrowRightSmallIcon />}
>
{t('Move to')}
</MenuItem>
<PivotsMenu
anchorEl={anchorEl}
open={open}
placement="left-start"
metas={metas.filter(meta => !meta.trash)}
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
</>
);
};

View File

@ -0,0 +1,60 @@
import { Confirm, MenuItem } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { DeleteTemporarilyIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useState } from 'react';
import { usePageMetaHelper } from '../../../hooks/use-page-meta';
import type { BlockSuiteWorkspace } from '../../../shared';
import { toast } from '../../../utils';
export const MoveToTrash = ({
currentMeta,
blockSuiteWorkspace,
testId,
}: {
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
testId?: string;
}) => {
const { t } = useTranslation();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [open, setOpen] = useState(false);
return (
<>
<MenuItem
data-testid={testId}
onClick={() => {
setOpen(true);
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Move to Trash')}
</MenuItem>
<Confirm
title={t('Delete page?')}
content={t('will be moved to Trash', {
title: currentMeta.title || 'Untitled',
})}
confirmText={t('Delete')}
confirmType="danger"
open={open}
onConfirm={() => {
toast(t('Moved to Trash'));
setOpen(false);
setPageMeta(currentMeta.id, {
trash: true,
trashDate: +new Date(),
});
}}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
/>
</>
);
};

View File

@ -0,0 +1,4 @@
export * from './CopyLink';
export * from './Export';
export * from './MoveTo';
export * from './MoveToTrash';

View File

@ -0,0 +1,103 @@
import { MuiClickAwayListener } from '@affine/component';
import { MoreVerticalIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useTheme } from '@mui/material';
import { useMemo, useState } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
import { OperationMenu } from './OperationMenu';
import { PivotsMenu } from './PivotsMenu/PivotsMenu';
import { StyledOperationButton } from './styles';
export type OperationButtonProps = {
onAdd: () => void;
onDelete: () => void;
metas: PageMeta[];
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
isHover: boolean;
onMenuClose?: () => void;
};
export const OperationButton = ({
onAdd,
onDelete,
metas,
currentMeta,
blockSuiteWorkspace,
isHover,
onMenuClose,
}: OperationButtonProps) => {
const {
zIndex: { modal: modalIndex },
} = useTheme();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [operationOpen, setOperationOpen] = useState(false);
const [pivotsMenuOpen, setPivotsMenuOpen] = useState(false);
const menuIndex = useMemo(() => modalIndex + 1, [modalIndex]);
return (
<MuiClickAwayListener
onClickAway={() => {
setOperationOpen(false);
setPivotsMenuOpen(false);
}}
>
<div
onClick={e => {
e.stopPropagation();
}}
onMouseLeave={() => {
setOperationOpen(false);
setPivotsMenuOpen(false);
}}
>
<StyledOperationButton
ref={ref => setAnchorEl(ref)}
size="small"
onClick={() => {
setOperationOpen(!operationOpen);
}}
visible={isHover}
>
<MoreVerticalIcon />
</StyledOperationButton>
<OperationMenu
anchorEl={anchorEl}
open={operationOpen}
placement="bottom-start"
zIndex={menuIndex}
onSelect={type => {
switch (type) {
case 'add':
onAdd();
break;
case 'move':
setPivotsMenuOpen(true);
break;
case 'delete':
onDelete();
break;
}
setOperationOpen(false);
onMenuClose?.();
}}
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
<PivotsMenu
anchorEl={anchorEl}
open={pivotsMenuOpen}
placement="bottom-start"
zIndex={menuIndex}
metas={metas}
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
showRemovePivots={true}
/>
</div>
</MuiClickAwayListener>
);
};

View File

@ -0,0 +1,74 @@
import type { PureMenuProps } from '@affine/component';
import { MenuItem, PureMenu } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { MoveToIcon, PenIcon, PlusIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import type { ReactElement } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
import { CopyLink, MoveToTrash } from '../operation-menu-items';
export type OperationMenuProps = {
onSelect: (type: OperationMenuItems['type']) => void;
blockSuiteWorkspace: BlockSuiteWorkspace;
currentMeta: PageMeta;
} & PureMenuProps;
export type OperationMenuItems = {
label: string;
icon: ReactElement;
type: 'add' | 'move' | 'rename' | 'delete' | 'copy';
disabled?: boolean;
};
const menuItems: OperationMenuItems[] = [
{
label: 'Add a subpage inside',
icon: <PlusIcon />,
type: 'add',
},
{
label: 'Move to',
icon: <MoveToIcon />,
type: 'move',
},
{
label: 'Rename',
icon: <PenIcon />,
type: 'rename',
disabled: true,
},
];
export const OperationMenu = ({
onSelect,
blockSuiteWorkspace,
currentMeta,
...menuProps
}: OperationMenuProps) => {
const { t } = useTranslation();
return (
<PureMenu width={256} {...menuProps}>
{menuItems.map((item, index) => {
return (
<MenuItem
key={index}
onClick={() => {
onSelect(item.type);
}}
icon={item.icon}
disabled={!!item.disabled}
>
{t(item.label)}
</MenuItem>
);
})}
<MoveToTrash
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
<CopyLink />
</PureMenu>
);
};

View File

@ -0,0 +1,65 @@
import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { workspacePreferredModeAtom } from '../../../atoms';
import { OperationButton } from './OperationButton';
import { StyledCollapsedButton, StyledPivot } from './styles';
import type { TreeNode } from './types';
export const PivotRender: TreeNode['render'] = (
node,
{ isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected },
renderProps
) => {
const {
onClick,
showOperationButton = false,
currentMeta,
metas = [],
blockSuiteWorkspace,
} = renderProps!;
const record = useAtomValue(workspacePreferredModeAtom);
const router = useRouter();
const [isHover, setIsHover] = useState(false);
const active = router.query.pageId === node.id;
return (
<StyledPivot
onClick={e => {
onClick?.(e, node);
}}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
isOver={isOver || isSelected}
active={active}
>
<StyledCollapsedButton
collapse={collapsed}
show={!!node.children?.length}
onClick={e => {
e.stopPropagation();
setCollapsed(node.id, !collapsed);
}}
>
<ArrowDownSmallIcon />
</StyledCollapsedButton>
{record[node.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
<span>{currentMeta?.title || 'Untitled'}</span>
{showOperationButton && (
<OperationButton
onAdd={onAdd}
onDelete={onDelete}
metas={metas}
currentMeta={currentMeta!}
blockSuiteWorkspace={blockSuiteWorkspace!}
isHover={isHover}
onMenuClose={() => setIsHover(false)}
/>
)}
</StyledPivot>
);
};

View File

@ -0,0 +1,10 @@
import { useTranslation } from '@affine/i18n';
import { StyledPivot } from '../styles';
export const EmptyItem = () => {
const { t } = useTranslation();
return <StyledPivot disable={true}>{t('No item')}</StyledPivot>;
};
export default EmptyItem;

View File

@ -0,0 +1,85 @@
import { MuiCollapse, TreeView } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
import type { MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import { usePivotData } from '../hooks/usePivotData';
import { usePivotHandler } from '../hooks/usePivotHandler';
import { PivotRender } from '../PivotRender';
import { StyledCollapsedButton, StyledPivot } from '../styles';
import EmptyItem from './EmptyItem';
import type { PivotsMenuProps } from './PivotsMenu';
export const Pivots = ({
metas,
blockSuiteWorkspace,
currentMeta,
}: Pick<PivotsMenuProps, 'metas' | 'blockSuiteWorkspace' | 'currentMeta'>) => {
const { t } = useTranslation();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [showPivot, setShowPivot] = useState(true);
const { handleDrop } = usePivotHandler({
blockSuiteWorkspace,
metas,
});
const { data } = usePivotData({
metas,
pivotRender: PivotRender,
blockSuiteWorkspace,
onClick: (e, node) => {
handleDrop(currentMeta.id, node.id, {
bottomLine: false,
topLine: false,
internal: true,
});
},
});
const isPivotEmpty = useMemo(
() => metas.filter(meta => !meta.trash).length === 0,
[metas]
);
return (
<>
<StyledPivot
onClick={() => {
setPageMeta(currentMeta.id, { isPivots: true });
}}
>
<StyledCollapsedButton
onClick={useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setShowPivot(!showPivot);
},
[showPivot]
)}
collapse={showPivot}
>
<ArrowDownSmallIcon />
</StyledCollapsedButton>
<PivotsIcon />
{t('Pivots')}
</StyledPivot>
<MuiCollapse
in={showPivot}
style={{
maxHeight: 300,
paddingLeft: '16px',
overflowY: 'auto',
}}
>
{isPivotEmpty ? (
<EmptyItem />
) : (
<TreeView data={data} indent={16} enableDnd={false} />
)}
</MuiCollapse>
</>
);
};
export default Pivots;

View File

@ -0,0 +1,109 @@
import type { PureMenuProps } from '@affine/component';
import { Input, PureMenu } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import React, { useState } from 'react';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import type { BlockSuiteWorkspace } from '../../../../shared';
import {
StyledMenuContent,
StyledMenuFooter,
StyledMenuSubTitle,
StyledPivot,
StyledSearchContainer,
} from '../styles';
import { Pivots } from './Pivots';
export type PivotsMenuProps = {
metas: PageMeta[];
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
showRemovePivots?: boolean;
} & PureMenuProps;
export const PivotsMenu = ({
metas,
currentMeta,
blockSuiteWorkspace,
showRemovePivots = false,
...pureMenuProps
}: PivotsMenuProps) => {
const { t } = useTranslation();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [query, setQuery] = useState('');
const isSearching = query.length > 0;
const searchResult = metas.filter(
meta => !meta.trash && meta.title.includes(query)
);
return (
<PureMenu width={320} height={480} {...pureMenuProps}>
<StyledSearchContainer>
<label>
<SearchIcon />
</label>
<Input
value={query}
onChange={setQuery}
placeholder={t('Move page to...')}
height={32}
noBorder={true}
onClick={e => e.stopPropagation()}
/>
</StyledSearchContainer>
<StyledMenuContent>
{isSearching && (
<>
<StyledMenuSubTitle>
{searchResult.length
? t('Find results', { number: searchResult.length })
: t('Find 0 result')}
</StyledMenuSubTitle>
{searchResult.map(meta => {
return <StyledPivot key={meta.id}>{meta.title}</StyledPivot>;
})}
</>
)}
{!isSearching && (
<>
<StyledMenuSubTitle>Suggested</StyledMenuSubTitle>
<Pivots
metas={metas}
blockSuiteWorkspace={blockSuiteWorkspace}
currentMeta={currentMeta}
/>
</>
)}
</StyledMenuContent>
{showRemovePivots && (
<StyledMenuFooter>
<StyledPivot
onClick={() => {
setPageMeta(currentMeta.id, { isPivots: false });
const parentMeta = metas.find(m =>
m.subpageIds.includes(currentMeta.id)
);
if (!parentMeta) return;
const newSubpageIds = [...parentMeta.subpageIds];
const deleteIndex = newSubpageIds.findIndex(
id => id === currentMeta.id
);
newSubpageIds.splice(deleteIndex, 1);
setPageMeta(parentMeta.id, { subpageIds: newSubpageIds });
}}
>
<RemoveIcon />
{t('Remove from Pivots')}
</StyledPivot>
<p>{t('RFP')}</p>
</StyledMenuFooter>
)}
</PureMenu>
);
};

View File

@ -1,13 +1,14 @@
import type { PageMeta } from '@blocksuite/store';
import { useMemo } from 'react';
import { TreeNodeRender } from './TreeNodeRender';
import type { TreeNode } from './types';
export const flattenToTree = (
handleMetas: PageMeta[],
renderProps: { openPage: (pageId: string) => void }
import type { RenderProps, TreeNode } from '../types';
const flattenToTree = (
metas: PageMeta[],
pivotRender: TreeNode['render'],
renderProps: RenderProps
): TreeNode[] => {
// Compatibility process: the old data not has `subpageIds`, it is a root page
const metas = JSON.parse(JSON.stringify(handleMetas)) as PageMeta[];
const rootMetas = metas
.filter(meta => {
if (meta.subpageIds) {
@ -19,29 +20,55 @@ export const flattenToTree = (
}
return true;
})
.filter(meta => !meta.trash);
.filter(meta => meta.isPivots === true);
const helper = (internalMetas: PageMeta[]): TreeNode[] => {
return internalMetas.reduce<TreeNode[]>((returnedMetas, internalMeta) => {
const { subpageIds = [] } = internalMeta;
const childrenMetas = subpageIds
.map(id => metas.find(m => m.id === id)!)
.filter(meta => !meta.trash);
// FIXME: remove ts-ignore after blocksuite update
.filter(m => m);
// @ts-ignore
const returnedMeta: TreeNode = {
...internalMeta,
children: helper(childrenMetas),
render: (node, props) =>
TreeNodeRender!(node, props, {
pageMeta: internalMeta,
pivotRender(node, props, {
...renderProps,
currentMeta: internalMeta,
metas,
}),
};
// @ts-ignore
returnedMetas.push(returnedMeta);
return returnedMetas;
}, []);
};
return helper(rootMetas);
};
export const usePivotData = ({
metas,
pivotRender,
blockSuiteWorkspace,
onClick,
showOperationButton,
}: {
metas: PageMeta[];
pivotRender: TreeNode['render'];
} & RenderProps) => {
const data = useMemo(
() =>
flattenToTree(metas, pivotRender, {
blockSuiteWorkspace,
onClick,
showOperationButton,
}),
[blockSuiteWorkspace, metas, onClick, pivotRender, showOperationButton]
);
return {
data,
};
};
export default usePivotData;

View File

@ -0,0 +1,198 @@
import type { TreeViewProps } from '@affine/component';
import { DebugLogger } from '@affine/debug';
import type { PageMeta } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import { useCallback } from 'react';
import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import type { BlockSuiteWorkspace } from '../../../../shared';
import type { NodeRenderProps, TreeNode } from '../types';
const logger = new DebugLogger('pivot');
const findRootIds = (metas: PageMeta[], id: string): string[] => {
const parentMeta = metas.find(m => m.subpageIds?.includes(id));
if (!parentMeta) {
return [id];
}
return [parentMeta.id, ...findRootIds(metas, parentMeta.id)];
};
export const usePivotHandler = ({
blockSuiteWorkspace,
metas,
onAdd,
onDelete,
onDrop,
}: {
blockSuiteWorkspace: BlockSuiteWorkspace;
metas: PageMeta[];
onAdd?: (addedId: string, parentId: string) => void;
onDelete?: TreeViewProps<NodeRenderProps>['onDelete'];
onDrop?: TreeViewProps<NodeRenderProps>['onDrop'];
}) => {
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const { getPageMeta, setPageMeta, shiftPageMeta } =
usePageMetaHelper(blockSuiteWorkspace);
const handleAdd = useCallback(
(node: TreeNode) => {
const id = nanoid();
createPage(id, node.id);
onAdd?.(id, node.id);
},
[createPage, onAdd]
);
const handleDelete = useCallback(
(node: TreeNode) => {
const removeToTrash = (currentMeta: PageMeta) => {
const { subpageIds = [] } = currentMeta;
setPageMeta(currentMeta.id, {
trash: true,
trashDate: +new Date(),
});
subpageIds.forEach(id => {
const subcurrentMeta = getPageMeta(id);
subcurrentMeta && removeToTrash(subcurrentMeta);
});
};
removeToTrash(metas.find(m => m.id === node.id)!);
onDelete?.(node);
},
[metas, getPageMeta, onDelete, setPageMeta]
);
const handleDrop = useCallback(
(
dragId: string,
dropId: string,
position: {
topLine: boolean;
bottomLine: boolean;
internal: boolean;
}
) => {
if (dragId === dropId) {
return;
}
const dropRootIds = findRootIds(metas, dropId);
if (dropRootIds.includes(dragId)) {
return;
}
const { topLine, bottomLine } = position;
logger.info('handleDrop', {
dragId,
dropId,
bottomLine,
metas,
});
const dragParentMeta = metas.find(meta =>
meta.subpageIds?.includes(dragId)
);
if (bottomLine || topLine) {
const insertOffset = bottomLine ? 1 : 0;
const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId));
if (!dropParentMeta) {
// drop into root
logger.info('drop into root and resort');
if (dragParentMeta) {
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
const deleteIndex = dragParentMeta.subpageIds?.findIndex(
id => id === dragId
);
newSubpageIds.splice(deleteIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
logger.info('resort root meta');
const insertIndex =
metas.findIndex(m => m.id === dropId) + insertOffset;
shiftPageMeta(dragId, insertIndex);
return onDrop?.(dragId, dropId, position);
}
if (
dragParentMeta &&
(dragParentMeta.id === dropId ||
dragParentMeta.id === dropParentMeta!.id)
) {
logger.info('drop to resort');
// need to resort
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
const deleteIndex = newSubpageIds.findIndex(id => id === dragId);
newSubpageIds.splice(deleteIndex, 1);
const insertIndex =
newSubpageIds.findIndex(id => id === dropId) + insertOffset;
newSubpageIds.splice(insertIndex, 0, dragId);
setPageMeta(dropParentMeta.id, {
subpageIds: newSubpageIds,
});
return onDrop?.(dragId, dropId, position);
}
logger.info('drop into drop node parent and resort');
if (dragParentMeta) {
const metaIndex = dragParentMeta.subpageIds.findIndex(
id => id === dragId
);
const newSubpageIds = [...dragParentMeta.subpageIds];
newSubpageIds.splice(metaIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])];
const insertIndex = newSubpageIds.findIndex(id => id === dropId) + 1;
newSubpageIds.splice(insertIndex, 0, dragId);
setPageMeta(dropParentMeta.id, {
subpageIds: newSubpageIds,
});
return onDrop?.(dragId, dropId, position);
}
logger.info('drop into the drop node');
// drop into the node
if (dragParentMeta && dragParentMeta.id === dropId) {
return;
}
if (dragParentMeta) {
const metaIndex = dragParentMeta.subpageIds.findIndex(
id => id === dragId
);
const newSubpageIds = [...dragParentMeta.subpageIds];
newSubpageIds.splice(metaIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
const dropMeta = metas.find(meta => meta.id === dropId)!;
const newSubpageIds = [dragId, ...(dropMeta.subpageIds ?? [])];
setPageMeta(dropMeta.id, {
subpageIds: newSubpageIds,
});
},
[metas, onDrop, setPageMeta, shiftPageMeta]
);
return {
handleDrop,
handleAdd,
handleDelete,
};
};
export default usePivotHandler;

View File

@ -0,0 +1,5 @@
export * from './hooks/usePivotData';
export * from './hooks/usePivotHandler';
export * from './PivotRender';
export * from './PivotsMenu/PivotsMenu';
export * from './types';

View File

@ -0,0 +1,117 @@
import {
alpha,
displayFlex,
IconButton,
styled,
textEllipsis,
} from '@affine/component';
export const StyledCollapsedButton = styled('button')<{
collapse: boolean;
show?: boolean;
}>(({ collapse, show = true, theme }) => {
return {
width: '16px',
height: '16px',
fontSize: '16px',
position: 'absolute',
left: '0',
top: '0',
bottom: '0',
margin: 'auto',
color: theme.colors.iconColor,
opacity: '.6',
display: show ? 'block' : 'none',
svg: {
transform: `rotate(${collapse ? '0' : '-90'}deg)`,
},
};
});
export const StyledPivot = styled('div')<{
disable?: boolean;
active?: boolean;
isOver?: boolean;
}>(({ disable = false, active = false, theme, isOver }) => {
return {
width: '100%',
height: '32px',
borderRadius: '8px',
...displayFlex('flex-start', 'center'),
padding: '0 2px 0 16px',
position: 'relative',
color: disable
? theme.colors.disableColor
: active
? theme.colors.primaryColor
: theme.colors.textColor,
cursor: disable ? 'not-allowed' : 'pointer',
background: isOver ? alpha(theme.colors.primaryColor, 0.06) : '',
fontSize: theme.font.base,
span: {
flexGrow: '1',
textAlign: 'left',
...textEllipsis(1),
},
'> svg': {
fontSize: '20px',
marginRight: '8px',
flexShrink: '0',
color: active ? theme.colors.primaryColor : theme.colors.iconColor,
},
':hover': {
backgroundColor: disable ? '' : theme.colors.hoverBackground,
},
};
});
export const StyledOperationButton = styled(IconButton)<{ visible: boolean }>(
({ visible }) => {
return {
visibility: visible ? 'visible' : 'hidden',
};
}
);
export const StyledSearchContainer = styled('div')(({ theme }) => {
return {
width: 'calc(100% - 24px)',
margin: '0 auto',
...displayFlex('flex-start', 'center'),
borderBottom: `1px solid ${theme.colors.borderColor}`,
label: {
color: theme.colors.iconColor,
fontSize: '20px',
height: '20px',
},
};
});
export const StyledMenuContent = styled('div')(() => {
return {
height: '266px',
overflow: 'auto',
};
});
export const StyledMenuSubTitle = styled('div')(({ theme }) => {
return {
color: theme.colors.secondaryTextColor,
lineHeight: '36px',
padding: '0 12px',
};
});
export const StyledMenuFooter = styled('div')(({ theme }) => {
return {
width: 'calc(100% - 24px)',
margin: '0 auto',
borderTop: `1px solid ${theme.colors.borderColor}`,
padding: '6px 0',
p: {
paddingLeft: '44px',
color: theme.colors.secondaryTextColor,
fontSize: '14px',
},
};
});

View File

@ -0,0 +1,18 @@
import type { Node } from '@affine/component';
import type { PageMeta } from '@blocksuite/store';
import type { MouseEvent } from 'react';
import type { BlockSuiteWorkspace } from '../../../shared';
export type RenderProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onClick?: (e: MouseEvent<HTMLDivElement>, node: TreeNode) => void;
showOperationButton?: boolean;
};
export type NodeRenderProps = RenderProps & {
metas: PageMeta[];
currentMeta: PageMeta;
};
export type TreeNode = Node<NodeRenderProps>;

View File

@ -1,8 +1,12 @@
import { styled } from '@affine/component';
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { Input } from '@affine/component';
import { MuiAvatar } from '@affine/component';
import {
Button,
Input,
Modal,
ModalCloseButton,
ModalWrapper,
MuiAvatar,
styled,
} from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { EmailIcon } from '@blocksuite/icons';
import type React from 'react';
@ -87,7 +91,7 @@ export const InviteMemberModal = ({
setShowMemberPreview(false);
}, [])}
placeholder={t('Invite placeholder')}
></Input>
/>
{showMemberPreview && gmailReg.test(email) && (
<Suspense fallback="loading...">
<Result workspaceId={workspaceId} queryEmail={email} />

View File

@ -20,10 +20,14 @@ import type { PageMeta } from '@blocksuite/store';
import type React from 'react';
import { useState } from 'react';
import type { BlockSuiteWorkspace } from '../../../../shared';
import { toast } from '../../../../utils';
import { MoveTo } from '../../../affine/operation-menu-items';
export type OperationCellProps = {
pageMeta: PageMeta;
metas: PageMeta[];
blockSuiteWorkspace: BlockSuiteWorkspace;
onOpenPageInNewTab: (pageId: string) => void;
onToggleFavoritePage: (pageId: string) => void;
onToggleTrashPage: (pageId: string) => void;
@ -31,6 +35,8 @@ export type OperationCellProps = {
export const OperationCell: React.FC<OperationCellProps> = ({
pageMeta,
metas,
blockSuiteWorkspace,
onOpenPageInNewTab,
onToggleFavoritePage,
onToggleTrashPage,
@ -59,6 +65,11 @@ export const OperationCell: React.FC<OperationCellProps> = ({
>
{t('Open in new tab')}
</MenuItem>
<MoveTo
metas={metas}
currentMeta={pageMeta}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
<MenuItem
data-testid="move-to-trash"
onClick={() => {

View File

@ -1,11 +1,13 @@
import {
Content,
IconButton,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
} from '@affine/component';
import { Content, IconButton, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
EdgelessIcon,
@ -223,6 +225,8 @@ export const PageList: React.FC<PageListProps> = ({
) : (
<OperationCell
pageMeta={pageMeta}
metas={pageList}
blockSuiteWorkspace={blockSuiteWorkspace}
onOpenPageInNewTab={pageId => {
onClickPage(pageId, true);
}}

View File

@ -1,21 +1,16 @@
// fixme(himself65): refactor this file
import { Confirm, FlexWrapper, Menu, MenuItem } from '@affine/component';
import { IconButton } from '@affine/component';
import { FlexWrapper, IconButton, Menu, MenuItem } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
DeleteTemporarilyIcon,
ExportIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
EdgelessIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
PageIcon,
} from '@blocksuite/icons';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store';
import { useTheme } from '@mui/material';
import { useAtom } from 'jotai';
import { useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id';
@ -25,6 +20,11 @@ import {
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
import { toast } from '../../../../utils';
import {
Export,
MoveTo,
MoveToTrash,
} from '../../../affine/operation-menu-items';
export const EditorOptionMenu = () => {
const { t } = useTranslation();
@ -39,12 +39,12 @@ export const EditorOptionMenu = () => {
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
const allMetas = usePageMeta(workspace?.blockSuiteWorkspace ?? null);
const [record, set] = useAtom(workspacePreferredModeAtom);
const mode = record[pageId] ?? 'page';
assertExists(pageMeta);
const { favorite, trash } = pageMeta;
const { favorite } = pageMeta;
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [open, setOpen] = useState(false);
const EditMenu = (
<>
@ -56,7 +56,6 @@ export const EditorOptionMenu = () => {
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
iconSize={[20, 20]}
icon={
favorite ? (
<FavoritedIcon style={{ color: theme.colors.primaryColor }} />
@ -69,7 +68,6 @@ export const EditorOptionMenu = () => {
</MenuItem>
<MenuItem
icon={mode === 'page' ? <EdgelessIcon /> : <PageIcon />}
iconSize={[20, 20]}
data-testid="editor-option-menu-edgeless"
onClick={() => {
set(record => ({
@ -81,48 +79,17 @@ export const EditorOptionMenu = () => {
{t('Convert to ')}
{mode === 'page' ? t('Edgeless') : t('Page')}
</MenuItem>
<Menu
width={248}
placement="left-start"
content={
<>
<MenuItem
onClick={() => {
// @ts-expect-error
globalThis.currentEditor.contentParser.onExportHtml();
}}
icon={<ExportToHtmlIcon />}
iconSize={[20, 20]}
>
{t('Export to HTML')}
</MenuItem>
<MenuItem
onClick={() => {
// @ts-expect-error
globalThis.currentEditor.contentParser.onExportMarkdown();
}}
icon={<ExportToMarkdownIcon />}
iconSize={[20, 20]}
>
{t('Export to Markdown')}
</MenuItem>
</>
}
>
<MenuItem icon={<ExportIcon />} iconSize={[20, 20]} isDir={true}>
{t('Export')}
</MenuItem>
</Menu>
<MenuItem
data-testid="editor-option-menu-delete"
onClick={() => {
setOpen(true);
}}
icon={<DeleteTemporarilyIcon />}
iconSize={[20, 20]}
>
{t('Delete')}
</MenuItem>
<Export />
<MoveTo
metas={allMetas}
currentMeta={pageMeta}
blockSuiteWorkspace={workspace?.blockSuiteWorkspace}
/>
<MoveToTrash
testId="editor-option-menu-delete"
currentMeta={pageMeta}
blockSuiteWorkspace={workspace?.blockSuiteWorkspace}
/>
</>
);
@ -141,26 +108,6 @@ export const EditorOptionMenu = () => {
</IconButton>
</Menu>
</FlexWrapper>
<Confirm
title={t('Delete page?')}
content={t('will be moved to Trash', {
title: pageMeta.title || 'Untitled',
})}
confirmText={t('Delete')}
confirmType="danger"
open={open}
onConfirm={() => {
toast(t('Moved to Trash'));
setOpen(false);
setPageMeta(pageId, { trash: !trash, trashDate: +new Date() });
}}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
/>
</>
);
};

View File

@ -0,0 +1,120 @@
import { MuiCollapse, TreeView } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import type { MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { RemWorkspace } from '../../../shared';
import type { TreeNode } from '../../affine/pivots';
import {
PivotRender,
usePivotData,
usePivotHandler,
} from '../../affine/pivots';
import EmptyItem from './favorite/empty-item';
import { StyledCollapseButton, StyledListItem } from './shared-styles';
export const PivotInternal = ({
currentWorkspace,
openPage,
allMetas,
}: {
currentWorkspace: RemWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
}) => {
const handlePivotClick = useCallback(
(e: MouseEvent<HTMLDivElement>, node: TreeNode) => {
openPage(node.id);
},
[openPage]
);
const onAdd = useCallback(
(id: string) => {
openPage(id);
},
[openPage]
);
const { data } = usePivotData({
metas: allMetas.filter(meta => !meta.trash),
pivotRender: PivotRender,
blockSuiteWorkspace: currentWorkspace.blockSuiteWorkspace,
onClick: handlePivotClick,
showOperationButton: true,
});
const { handleAdd, handleDelete, handleDrop } = usePivotHandler({
blockSuiteWorkspace: currentWorkspace.blockSuiteWorkspace,
metas: allMetas,
onAdd,
});
return (
<TreeView
data={data}
onAdd={handleAdd}
onDelete={handleDelete}
onDrop={handleDrop}
indent={16}
/>
);
};
export const Pivots = ({
currentWorkspace,
openPage,
allMetas,
}: {
currentWorkspace: RemWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
}) => {
const { t } = useTranslation();
const [showPivot, setShowPivot] = useState(true);
const isPivotEmpty = useMemo(
() => allMetas.filter(meta => !meta.trash).length === 0,
[allMetas]
);
return (
<>
<StyledListItem>
<StyledCollapseButton
onClick={useCallback(() => {
setShowPivot(!showPivot);
}, [showPivot])}
collapse={showPivot}
>
<ArrowDownSmallIcon />
</StyledCollapseButton>
<PivotsIcon />
{t('Pivots')}
</StyledListItem>
<MuiCollapse
in={showPivot}
style={{
maxHeight: 300,
paddingLeft: '16px',
overflowY: 'auto',
}}
>
{isPivotEmpty ? (
<EmptyItem />
) : (
<PivotInternal
currentWorkspace={currentWorkspace}
openPage={openPage}
allMetas={allMetas}
/>
)}
</MuiCollapse>
</>
);
};
export default Pivots;

View File

@ -16,7 +16,7 @@ import { usePageMeta } from '../../../hooks/use-page-meta';
import type { RemWorkspace } from '../../../shared';
import { SidebarSwitch } from '../../affine/sidebar-switch';
import Favorite from './favorite';
import { Pivot } from './pivot';
import { Pivots } from './Pivots';
import { StyledListItem } from './shared-styles';
import {
StyledLink,
@ -142,7 +142,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
currentWorkspace={currentWorkspace}
/>
{config.enableSubpage && !!currentWorkspace && (
<Pivot
<Pivots
currentWorkspace={currentWorkspace}
openPage={openPage}
allMetas={pageMeta}

View File

@ -1,110 +0,0 @@
import {
IconButton,
MenuItem,
MuiClickAwayListener,
PureMenu,
} from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
CopyIcon,
DeleteTemporarilyIcon,
MoreVerticalIcon,
MoveToIcon,
PenIcon,
PlusIcon,
} from '@blocksuite/icons';
import { useRouter } from 'next/router';
import { useCallback, useState } from 'react';
import { toast } from '../../../../utils';
export const OperationButton = ({
onAdd,
onDelete,
}: {
onAdd: () => void;
onDelete: () => void;
}) => {
const { t } = useTranslation();
const router = useRouter();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [open, setOpen] = useState(false);
const copyUrl = useCallback(() => {
const workspaceId = router.query.workspaceId;
navigator.clipboard.writeText(window.location.href);
toast(t('Copied link to clipboard'));
}, [router.query.workspaceId, t]);
return (
<MuiClickAwayListener
onClickAway={() => {
setOpen(false);
}}
>
<div
onClick={e => {
e.stopPropagation();
}}
onMouseLeave={() => {
setOpen(false);
}}
>
<IconButton
ref={ref => setAnchorEl(ref)}
size="small"
className="operation-button"
onClick={event => {
event.stopPropagation();
setOpen(!open);
}}
>
<MoreVerticalIcon />
</IconButton>
<PureMenu
anchorEl={anchorEl}
placement="bottom-start"
open={open && anchorEl !== null}
zIndex={11111}
>
<MenuItem
icon={<PlusIcon />}
onClick={() => {
onAdd();
setOpen(false);
}}
>
{t('Add a subpage inside')}
</MenuItem>
<MenuItem icon={<MoveToIcon />} disabled={true}>
{t('Move to')}
</MenuItem>
<MenuItem icon={<PenIcon />} disabled={true}>
{t('Rename')}
</MenuItem>
<MenuItem
icon={<DeleteTemporarilyIcon />}
onClick={() => {
onDelete();
setOpen(false);
}}
>
{t('Move to Trash')}
</MenuItem>
<MenuItem
icon={<CopyIcon />}
disabled={true}
// onClick={() => {
// const workspaceId = router.query.workspaceId;
// navigator.clipboard.writeText(window.location.href);
// toast(t('Copied link to clipboard'));
// }}
>
{t('Copy Link')}
</MenuItem>
</PureMenu>
</div>
</MuiClickAwayListener>
);
};

View File

@ -1,242 +0,0 @@
import { MuiCollapse, TreeView } from '@affine/component';
import { DebugLogger } from '@affine/debug';
import { useTranslation } from '@affine/i18n';
import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import { useCallback, useMemo, useState } from 'react';
import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper';
import { usePageMetaHelper } from '../../../../hooks/use-page-meta';
import type { RemWorkspace } from '../../../../shared';
import EmptyItem from '../favorite/empty-item';
import { StyledCollapseButton, StyledListItem } from '../shared-styles';
import type { TreeNode } from './types';
import { flattenToTree } from './utils';
const logger = new DebugLogger('pivot');
export const PivotInternal = ({
currentWorkspace,
openPage,
allMetas,
}: {
currentWorkspace: RemWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
}) => {
const { createPage } = useBlockSuiteWorkspaceHelper(
currentWorkspace.blockSuiteWorkspace
);
const { getPageMeta, setPageMeta, shiftPageMeta } = usePageMetaHelper(
currentWorkspace.blockSuiteWorkspace
);
const treeData = useMemo(
() => flattenToTree(allMetas, { openPage }),
[allMetas, openPage]
);
const handleAdd = useCallback(
(node: TreeNode) => {
const id = nanoid();
createPage(id, node.id);
openPage(id);
},
[createPage, openPage]
);
const handleDelete = useCallback(
(node: TreeNode) => {
const removeToTrash = (pageMeta: PageMeta) => {
const { subpageIds = [] } = pageMeta;
setPageMeta(pageMeta.id, { trash: true, trashDate: +new Date() });
subpageIds.forEach(id => {
const subpageMeta = getPageMeta(id);
subpageMeta && removeToTrash(subpageMeta);
});
};
removeToTrash(node as PageMeta);
},
[getPageMeta, setPageMeta]
);
const handleDrop = useCallback(
(
dragNode: TreeNode,
dropNode: TreeNode,
position: {
topLine: boolean;
bottomLine: boolean;
internal: boolean;
}
) => {
const { topLine, bottomLine } = position;
logger.info('handleDrop', { dragNode, dropNode, bottomLine, allMetas });
const dragParentMeta = allMetas.find(meta =>
meta.subpageIds?.includes(dragNode.id)
);
if (bottomLine || topLine) {
const insertOffset = bottomLine ? 1 : 0;
const dropParentMeta = allMetas.find(m =>
m.subpageIds?.includes(dropNode.id)
);
if (!dropParentMeta) {
// drop into root
logger.info('drop into root and resort');
if (dragParentMeta) {
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
const deleteIndex = dragParentMeta.subpageIds?.findIndex(
id => id === dragNode.id
);
newSubpageIds.splice(deleteIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
logger.info('resort root meta');
const insertIndex =
allMetas.findIndex(m => m.id === dropNode.id) + insertOffset;
shiftPageMeta(dragNode.id, insertIndex);
return;
}
if (
dragParentMeta &&
(dragParentMeta.id === dropNode.id ||
dragParentMeta.id === dropParentMeta!.id)
) {
logger.info('drop to resort');
// need to resort
const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])];
const deleteIndex = newSubpageIds.findIndex(id => id === dragNode.id);
newSubpageIds.splice(deleteIndex, 1);
const insertIndex =
newSubpageIds.findIndex(id => id === dropNode.id) + insertOffset;
newSubpageIds.splice(insertIndex, 0, dragNode.id);
setPageMeta(dropParentMeta.id, {
subpageIds: newSubpageIds,
});
return;
}
logger.info('drop into drop node parent and resort');
if (dragParentMeta) {
const metaIndex = dragParentMeta.subpageIds.findIndex(
id => id === dragNode.id
);
const newSubpageIds = [...dragParentMeta.subpageIds];
newSubpageIds.splice(metaIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])];
const insertIndex =
newSubpageIds.findIndex(id => id === dropNode.id) + 1;
newSubpageIds.splice(insertIndex, 0, dragNode.id);
setPageMeta(dropParentMeta.id, {
subpageIds: newSubpageIds,
});
return;
}
logger.info('drop into the drop node');
// drop into the node
if (dragParentMeta && dragParentMeta.id === dropNode.id) {
return;
}
if (dragParentMeta) {
const metaIndex = dragParentMeta.subpageIds.findIndex(
id => id === dragNode.id
);
const newSubpageIds = [...dragParentMeta.subpageIds];
newSubpageIds.splice(metaIndex, 1);
setPageMeta(dragParentMeta.id, {
subpageIds: newSubpageIds,
});
}
const dropMeta = allMetas.find(meta => meta.id === dropNode.id)!;
const newSubpageIds = [dragNode.id, ...(dropMeta.subpageIds ?? [])];
setPageMeta(dropMeta.id, {
subpageIds: newSubpageIds,
});
},
[allMetas, setPageMeta, shiftPageMeta]
);
return (
<TreeView
data={treeData}
onAdd={handleAdd}
onDelete={handleDelete}
onDrop={handleDrop}
indent={16}
/>
);
};
export const Pivot = ({
currentWorkspace,
openPage,
allMetas,
}: {
currentWorkspace: RemWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
}) => {
const { t } = useTranslation();
const [showPivot, setShowPivot] = useState(true);
const isPivotEmpty = useMemo(
() => allMetas.filter(meta => !meta.trash).length === 0,
[allMetas]
);
return (
<>
<StyledListItem>
<StyledCollapseButton
onClick={useCallback(() => {
setShowPivot(!showPivot);
}, [showPivot])}
collapse={showPivot}
>
<ArrowDownSmallIcon />
</StyledCollapseButton>
<PivotsIcon />
{t('Pivots')}
</StyledListItem>
<MuiCollapse
in={showPivot}
style={{
maxHeight: 300,
paddingLeft: '16px',
overflowY: 'auto',
}}
>
{isPivotEmpty ? (
<EmptyItem />
) : (
<PivotInternal
currentWorkspace={currentWorkspace}
openPage={openPage}
allMetas={allMetas}
/>
)}
</MuiCollapse>
</>
);
};
export default Pivot;

View File

@ -1,51 +0,0 @@
import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { workspacePreferredModeAtom } from '../../../../atoms';
import { StyledCollapseButton, StyledCollapseItem } from '../shared-styles';
import { OperationButton } from './OperationButton';
import type { TreeNode } from './types';
export const TreeNodeRender: TreeNode['render'] = (
node,
{ isOver, onAdd, onDelete, collapsed, setCollapsed },
extendProps
) => {
const { openPage, pageMeta } = extendProps as {
openPage: (pageId: string) => void;
pageMeta: PageMeta;
};
const record = useAtomValue(workspacePreferredModeAtom);
const router = useRouter();
const active = router.query.pageId === node.id;
return (
<StyledCollapseItem
onClick={() => {
if (active) {
return;
}
openPage(node.id);
}}
isOver={isOver}
active={active}
>
<StyledCollapseButton
collapse={collapsed}
show={!!node.children?.length}
onClick={e => {
e.stopPropagation();
setCollapsed(!collapsed);
}}
>
<ArrowDownSmallIcon />
</StyledCollapseButton>
{record[pageMeta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
<span>{node.title || 'Untitled'}</span>
<OperationButton onAdd={onAdd} onDelete={onDelete} />
</StyledCollapseItem>
);
};

View File

@ -1,2 +0,0 @@
export * from './Pivot';
export * from './types';

View File

@ -1,29 +0,0 @@
import { IconButton, styled } from '@affine/component';
export const StyledOperationButton = styled('button')(({ theme }) => {
return {
height: '20px',
width: '20px',
fontSize: '20px',
color: theme.colors.iconColor,
display: 'none',
':hover': {
background: theme.colors.hoverBackground,
},
};
});
export const StyledCollapsedButton = styled(IconButton, {
shouldForwardProp: prop => {
return !['show'].includes(prop as string);
},
})<{ show: boolean }>(({ show }) => {
return {
display: show ? 'block' : 'none',
position: 'absolute',
left: '0px',
top: '0px',
bottom: '0px',
margin: 'auto',
};
});

View File

@ -1,4 +0,0 @@
import type { Node } from '@affine/component';
import type { PageMeta } from '@blocksuite/store';
export type TreeNode = Node<PageMeta>;

View File

@ -11,6 +11,8 @@ declare module '@blocksuite/store' {
trashDate?: number;
// whether to create the page with the default template
init?: boolean;
// use for subpage
isPivots?: boolean;
}
}

View File

@ -1,12 +1,12 @@
import type {
CSSProperties,
FocusEventHandler,
ForwardedRef,
HTMLAttributes,
InputHTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { forwardRef } from 'react';
import { useEffect, useState } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { StyledInput } from './style';
@ -14,13 +14,14 @@ type inputProps = {
value?: string;
placeholder?: string;
disabled?: boolean;
width?: number;
height?: number;
width?: CSSProperties['width'];
height?: CSSProperties['height'];
maxLength?: number;
minLength?: number;
onChange?: (value: string) => void;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
noBorder?: boolean;
} & Omit<HTMLAttributes<HTMLInputElement>, 'onChange'>;
export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
@ -31,10 +32,11 @@ export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
maxLength,
minLength,
height,
width = 260,
width,
onChange,
onBlur,
onKeyDown,
noBorder = false,
...otherProps
}: inputProps,
ref: ForwardedRef<HTMLInputElement>
@ -69,7 +71,8 @@ export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
onBlur={handleBlur}
onKeyDown={handleKeyDown}
height={height}
noBorder={noBorder}
{...otherProps}
></StyledInput>
/>
);
});

View File

@ -1,28 +1,25 @@
import type { CSSProperties } from 'react';
import { styled } from '../../styles';
export const StyledInput = styled('input')<{
disabled?: boolean;
value?: string;
width: number;
height?: number;
}>(({ theme, width, disabled, height }) => {
const fontWeight = 400;
const fontSize = '16px';
width?: CSSProperties['width'];
height?: CSSProperties['height'];
noBorder?: boolean;
}>(({ theme, width, disabled, height, noBorder }) => {
return {
width: `${width}px`,
width: width || '100%',
height,
lineHeight: '22px',
padding: '8px 12px',
fontWeight,
fontSize,
height: height ? `${height}px` : 'auto',
color: disabled ? theme.colors.disableColor : theme.colors.textColor,
border: `1px solid`,
border: noBorder ? 'unset' : `1px solid`,
borderColor: theme.colors.borderColor, // TODO: check out disableColor,
backgroundColor: theme.colors.popoverBackground,
borderRadius: '10px',
'&::placeholder': {
fontWeight,
fontSize,
color: theme.colors.placeHolderColor,
},
'&:focus': {

View File

@ -1,31 +1,28 @@
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
import { cloneElement, forwardRef } from 'react';
import { forwardRef } from 'react';
import {
StyledContent,
StyledEndIconWrapper,
StyledMenuItem,
StyledStartIconWrapper,
} from './styles';
import { StyledArrow, StyledMenuItem } from './styles';
export type IconMenuProps = PropsWithChildren<{
isDir?: boolean;
icon?: ReactElement;
endIcon?: ReactElement;
iconSize?: [number, number];
disabled?: boolean;
}> &
HTMLAttributes<HTMLButtonElement>;
export const MenuItem = forwardRef<HTMLButtonElement, IconMenuProps>(
({ isDir = false, icon, iconSize, children, ...props }, ref) => {
const [iconWidth, iconHeight] = iconSize || [20, 20];
({ endIcon, icon, iconSize, children, ...props }, ref) => {
return (
<StyledMenuItem ref={ref} {...props}>
{icon &&
cloneElement(icon, {
width: iconWidth,
height: iconHeight,
style: {
marginRight: 12,
...icon.props?.style,
},
})}
{children}
{isDir ? <StyledArrow /> : null}
{icon && <StyledStartIconWrapper>{icon}</StyledStartIconWrapper>}
<StyledContent>{children}</StyledContent>
{endIcon && <StyledEndIconWrapper>{endIcon}</StyledEndIconWrapper>}
</StyledMenuItem>
);
}

View File

@ -4,12 +4,17 @@ import type { PurePopperProps } from '../popper';
import { PurePopper } from '../popper';
import { StyledMenuWrapper } from './styles';
export type PureMenuProps = PurePopperProps & {
width?: CSSProperties['width'];
height?: CSSProperties['height'];
};
export const PureMenu = ({
children,
placement,
width,
height,
...otherProps
}: PurePopperProps & { width?: CSSProperties['width'] }) => {
}: PureMenuProps) => {
return (
<PurePopper placement={placement} {...otherProps}>
<StyledMenuWrapper width={width} placement={placement}>

View File

@ -1,14 +1,15 @@
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import type { CSSProperties } from 'react';
import { displayFlex, styled } from '../../styles';
import { displayFlex, styled, textEllipsis } from '../../styles';
import StyledPopperContainer from '../shared/Container';
export const StyledMenuWrapper = styled(StyledPopperContainer)<{
width?: CSSProperties['width'];
}>(({ theme, width }) => {
height?: CSSProperties['height'];
}>(({ theme, width, height }) => {
return {
width,
height,
background: theme.colors.popoverBackground,
padding: '8px 4px',
fontSize: '14px',
@ -17,13 +18,28 @@ export const StyledMenuWrapper = styled(StyledPopperContainer)<{
};
});
export const StyledArrow = styled(ArrowRightSmallIcon)({
position: 'absolute',
right: '12px',
top: 0,
bottom: 0,
margin: 'auto',
fontSize: '20px',
export const StyledStartIconWrapper = styled('div')(({ theme }) => {
return {
marginRight: '12px',
fontSize: '20px',
color: theme.colors.iconColor,
};
});
export const StyledEndIconWrapper = styled('div')(({ theme }) => {
return {
marginLeft: '12px',
fontSize: '20px',
color: theme.colors.iconColor,
};
});
export const StyledContent = styled('div')(({ theme }) => {
return {
textAlign: 'left',
flexGrow: 1,
fontSize: theme.font.base,
...textEllipsis(1),
};
});
export const StyledMenuItem = styled('button')<{

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import {
@ -14,21 +14,21 @@ import type {
TreeNodeProps,
} from './types';
const NodeLine = <N,>({
const NodeLine = <RenderProps,>({
node,
onDrop,
allowDrop = true,
isTop = false,
}: NodeLIneProps<N>) => {
}: NodeLIneProps<RenderProps>) => {
const [{ isOver }, drop] = useDrop(
() => ({
accept: 'node',
drop: (item: Node<N>, monitor) => {
drop: (item: Node<RenderProps>, monitor) => {
const didDrop = monitor.didDrop();
if (didDrop) {
return;
}
onDrop?.(item, node, {
onDrop?.(item.id, node.id, {
internal: false,
topLine: isTop,
bottomLine: !isTop,
@ -44,24 +44,23 @@ const NodeLine = <N,>({
return <StyledNodeLine ref={drop} show={isOver && allowDrop} isTop={isTop} />;
};
const TreeNodeItem = <N,>({
const TreeNodeItemWithDnd = <RenderProps,>({
node,
allowDrop,
collapsed,
setCollapsed,
...otherProps
}: TreeNodeItemProps<N>) => {
}: TreeNodeItemProps<RenderProps>) => {
const { onAdd, onDelete, onDrop } = otherProps;
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
accept: 'node',
drop: (item: Node<N>, monitor) => {
drop: (item: Node<RenderProps>, monitor) => {
const didDrop = monitor.didDrop();
if (didDrop || item.id === node.id || !allowDrop) {
return;
}
onDrop?.(item, node, {
onDrop?.(item.id, node.id, {
internal: true,
topLine: false,
bottomLine: false,
@ -77,44 +76,79 @@ const TreeNodeItem = <N,>({
useEffect(() => {
if (isOver && canDrop) {
setCollapsed(false);
setCollapsed(node.id, false);
}
}, [isOver, canDrop]);
return (
<div ref={drop}>
<TreeNodeItem
dropRef={drop}
onAdd={onAdd}
onDelete={onDelete}
node={node}
allowDrop={allowDrop}
setCollapsed={setCollapsed}
isOver={isOver}
canDrop={canDrop}
{...otherProps}
/>
);
};
const TreeNodeItem = <RenderProps,>({
node,
collapsed,
setCollapsed,
selectedId,
isOver = false,
canDrop = false,
onAdd,
onDelete,
dropRef,
}: TreeNodeItemProps<RenderProps>) => {
return (
<div ref={dropRef}>
{node.render?.(node, {
isOver: !!(isOver && canDrop),
isOver: isOver && canDrop,
onAdd: () => onAdd?.(node),
onDelete: () => onDelete?.(node),
collapsed,
setCollapsed,
isSelected: selectedId === node.id,
})}
</div>
);
};
export const TreeNode = <N,>({
node,
index,
allowDrop = true,
...otherProps
}: TreeNodeProps<N>) => {
const { indent } = otherProps;
const [collapsed, setCollapsed] = useState(false);
export const TreeNodeWithDnd = <RenderProps,>(
props: TreeNodeProps<RenderProps>
) => {
const [{ isDragging }, drag] = useDrag(() => ({
type: 'node',
item: node,
item: props.node,
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
}));
return <TreeNode dragRef={drag} isDragging={isDragging} {...props} />;
};
export const TreeNode = <RenderProps,>({
node,
index,
isDragging = false,
allowDrop = true,
dragRef,
...otherProps
}: TreeNodeProps<RenderProps>) => {
const { indent, enableDnd, collapsedIds } = otherProps;
const collapsed = collapsedIds.includes(node.id);
return (
<StyledTreeNodeContainer ref={drag} isDragging={isDragging}>
<StyledTreeNodeContainer ref={dragRef} isDragging={isDragging}>
<StyledTreeNodeWrapper>
{index === 0 && (
{enableDnd && index === 0 && (
<NodeLine
node={node}
{...otherProps}
@ -122,15 +156,25 @@ export const TreeNode = <N,>({
isTop={true}
/>
)}
<TreeNodeItem
node={node}
index={index}
allowDrop={allowDrop}
collapsed={collapsed}
setCollapsed={setCollapsed}
{...otherProps}
/>
{(!node.children?.length || collapsed) && (
{enableDnd ? (
<TreeNodeItemWithDnd
node={node}
index={index}
allowDrop={allowDrop}
collapsed={collapsed}
{...otherProps}
/>
) : (
<TreeNodeItem
node={node}
index={index}
allowDrop={allowDrop}
collapsed={collapsed}
{...otherProps}
/>
)}
{enableDnd && (!node.children?.length || collapsed) && (
<NodeLine
node={node}
{...otherProps}
@ -140,18 +184,26 @@ export const TreeNode = <N,>({
</StyledTreeNodeWrapper>
<StyledCollapse in={!collapsed} indent={indent}>
{node.children &&
node.children.map((childNode, index) => (
<TreeNode
key={childNode.id}
node={childNode}
index={index}
allowDrop={isDragging ? false : allowDrop}
{...otherProps}
/>
))}
node.children.map((childNode, index) =>
enableDnd ? (
<TreeNodeWithDnd
key={childNode.id}
node={childNode}
index={index}
allowDrop={isDragging ? false : allowDrop}
{...otherProps}
/>
) : (
<TreeNode
key={childNode.id}
node={childNode}
index={index}
allowDrop={false}
{...otherProps}
/>
)
)}
</StyledCollapse>
</StyledTreeNodeContainer>
);
};
export default TreeNode;

View File

@ -1,15 +1,108 @@
import { useEffect, useState } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TreeNode } from './TreeNode';
import type { TreeViewProps } from './types';
export const TreeView = <N,>({ data, ...otherProps }: TreeViewProps<N>) => {
import { TreeNode, TreeNodeWithDnd } from './TreeNode';
import type { TreeNodeProps, TreeViewProps } from './types';
import { flattenIds } from './utils';
export const TreeView = <RenderProps,>({
data,
enableKeyboardSelection,
onSelect,
enableDnd = true,
initialCollapsedIds = [],
...otherProps
}: TreeViewProps<RenderProps>) => {
const [selectedId, setSelectedId] = useState<string>();
// TODO: should record collapsedIds in localStorage
const [collapsedIds, setCollapsedIds] =
useState<string[]>(initialCollapsedIds);
useEffect(() => {
if (!enableKeyboardSelection) {
return;
}
const flattenedIds = flattenIds<RenderProps>(data);
const handleDirectionKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
return;
}
if (selectedId === undefined) {
setSelectedId(flattenedIds[0]);
return;
}
let selectedIndex = flattenedIds.indexOf(selectedId);
if (e.key === 'ArrowDown') {
selectedIndex < flattenedIds.length - 1 && selectedIndex++;
}
if (e.key === 'ArrowUp') {
selectedIndex > 0 && selectedIndex--;
}
setSelectedId(flattenedIds[selectedIndex]);
};
const handleEnterKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
return;
}
selectedId && onSelect?.(selectedId);
};
document.addEventListener('keydown', handleDirectionKeyDown);
document.addEventListener('keydown', handleEnterKeyDown);
return () => {
document.removeEventListener('keydown', handleDirectionKeyDown);
document.removeEventListener('keydown', handleEnterKeyDown);
};
}, [data, selectedId]);
const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => {
if (collapsed) {
setCollapsedIds(ids => [...ids, id]);
} else {
setCollapsedIds(ids => ids.filter(i => i !== id));
}
};
if (enableDnd) {
return (
<DndProvider backend={HTML5Backend}>
{data.map((node, index) => (
<TreeNodeWithDnd
key={node.id}
index={index}
collapsedIds={collapsedIds}
setCollapsed={setCollapsed}
node={node}
selectedId={selectedId}
enableDnd={enableDnd}
{...otherProps}
/>
))}
</DndProvider>
);
}
return (
<DndProvider backend={HTML5Backend}>
<>
{data.map((node, index) => (
<TreeNode key={node.id} index={index} node={node} {...otherProps} />
<TreeNode
key={node.id}
index={index}
collapsedIds={collapsedIds}
setCollapsed={setCollapsed}
node={node}
selectedId={selectedId}
enableDnd={enableDnd}
{...otherProps}
/>
))}
</DndProvider>
</>
);
};

View File

@ -15,8 +15,8 @@ export const StyledTreeNodeWrapper = styled('div')(() => {
position: 'relative',
};
});
export const StyledTreeNodeContainer = styled('div')<{ isDragging: boolean }>(
({ isDragging, theme }) => {
export const StyledTreeNodeContainer = styled('div')<{ isDragging?: boolean }>(
({ isDragging = false, theme }) => {
return {
background: isDragging ? theme.colors.hoverBackground : '',
// opacity: isDragging ? 0.4 : 1,

View File

@ -1,52 +1,71 @@
import type { CSSProperties, ReactNode } from 'react';
import type { CSSProperties, ReactNode, Ref } from 'react';
export type Node<N> = {
export type DropPosition = {
topLine: boolean;
bottomLine: boolean;
internal: boolean;
};
export type OnDrop = (
dragId: string,
dropId: string,
position: DropPosition
) => void;
export type Node<RenderProps = unknown> = {
id: string;
children?: Node<N>[];
render?: (
node: Node<N>,
children?: Node<RenderProps>[];
render: (
node: Node<RenderProps>,
eventsAndStatus: {
isOver: boolean;
onAdd: () => void;
onDelete: () => void;
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
setCollapsed: (id: string, collapsed: boolean) => void;
isSelected: boolean;
},
extendProps?: unknown
renderProps?: RenderProps
) => ReactNode;
} & N;
type CommonProps<N> = {
indent?: CSSProperties['paddingLeft'];
onAdd?: (node: Node<N>) => void;
onDelete?: (node: Node<N>) => void;
onDrop?: (
dragNode: Node<N>,
dropNode: Node<N>,
position: {
topLine: boolean;
bottomLine: boolean;
internal: boolean;
}
) => void;
};
export type TreeNodeProps<N> = {
node: Node<N>;
type CommonProps<RenderProps = unknown> = {
enableDnd?: boolean;
enableKeyboardSelection?: boolean;
indent?: CSSProperties['paddingLeft'];
onAdd?: (node: Node<RenderProps>) => void;
onDelete?: (node: Node<RenderProps>) => void;
onDrop?: OnDrop;
// Only trigger when the enableKeyboardSelection is true
onSelect?: (id: string) => void;
};
export type TreeNodeProps<RenderProps = unknown> = {
node: Node<RenderProps>;
index: number;
collapsedIds: string[];
setCollapsed: (id: string, collapsed: boolean) => void;
allowDrop?: boolean;
} & CommonProps<N>;
selectedId?: string;
isDragging?: boolean;
dragRef?: Ref<HTMLDivElement>;
} & CommonProps<RenderProps>;
export type TreeNodeItemProps<N> = {
export type TreeNodeItemProps<RenderProps = unknown> = {
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
} & TreeNodeProps<N>;
setCollapsed: (id: string, collapsed: boolean) => void;
export type TreeViewProps<N> = {
data: Node<N>[];
} & CommonProps<N>;
isOver?: boolean;
canDrop?: boolean;
export type NodeLIneProps<N> = {
dropRef?: Ref<HTMLDivElement>;
} & TreeNodeProps<RenderProps>;
export type TreeViewProps<RenderProps = unknown> = {
data: Node<RenderProps>[];
initialCollapsedIds?: string[];
} & CommonProps<RenderProps>;
export type NodeLIneProps<RenderProps = unknown> = {
allowDrop: boolean;
isTop?: boolean;
} & Pick<TreeNodeProps<N>, 'node' | 'onDrop'>;
} & Pick<TreeNodeProps<RenderProps>, 'node' | 'onDrop'>;

View File

@ -0,0 +1,18 @@
import type { Node } from '@affine/component';
export function flattenIds<RenderProps>(arr: Node<RenderProps>[]): string[] {
const result: string[] = [];
function flatten(arr: Node<RenderProps>[]) {
for (let i = 0, len = arr.length; i < len; i++) {
const item = arr[i];
result.push(item.id);
if (Array.isArray(item.children)) {
flatten(item.children);
}
}
}
flatten(arr);
return result;
}

View File

@ -197,5 +197,8 @@
"Pivots": "Pivots",
"Add a subpage inside": "Add a subpage inside",
"Rename": "Rename",
"Move to": "Move to"
"Move to": "Move to",
"Move page to...": "Move page to...",
"Remove from Pivots": "Remove from Pivots",
"RFP": "Pages can be freely added/removed from pivots, remaining accessible from \"All Pages\"."
}