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
40 changed files with 1513 additions and 665 deletions

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

@@ -1,47 +0,0 @@
import type { PageMeta } from '@blocksuite/store';
import { TreeNodeRender } from './TreeNodeRender';
import type { TreeNode } from './types';
export const flattenToTree = (
handleMetas: PageMeta[],
renderProps: { openPage: (pageId: string) => void }
): 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) {
return (
metas.find(m => {
return m.subpageIds?.includes(meta.id);
}) === undefined
);
}
return true;
})
.filter(meta => !meta.trash);
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
// @ts-ignore
const returnedMeta: TreeNode = {
...internalMeta,
children: helper(childrenMetas),
render: (node, props) =>
TreeNodeRender!(node, props, {
pageMeta: internalMeta,
...renderProps,
}),
};
// @ts-ignore
returnedMetas.push(returnedMeta);
return returnedMetas;
}, []);
};
return helper(rootMetas);
};