mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat: support pivots menu (#1755)
This commit is contained in:
120
apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx
Normal file
120
apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Pivot';
|
||||
export * from './types';
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { Node } from '@affine/component';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
export type TreeNode = Node<PageMeta>;
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user