feat: support subpage (#1663)

This commit is contained in:
Qi
2023-03-23 13:47:07 +08:00
committed by GitHub
parent 2551785451
commit 6a7b5601aa
26 changed files with 824 additions and 82 deletions

View File

@@ -15,3 +15,4 @@ PREFETCH_WORKSPACE=1
ENABLE_BC_PROVIDER=1
EXPOSE_INTERNAL=1
ENABLE_DEBUG_PAGE=
ENABLE_SUBPAGE=

View File

@@ -15,10 +15,10 @@
"@affine/env": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/blocks": "0.5.0-20230320164115-e612d17",
"@blocksuite/editor": "0.5.0-20230320164115-e612d17",
"@blocksuite/blocks": "0.5.0-20230323032255-e75ed32",
"@blocksuite/editor": "0.5.0-20230323032255-e75ed32",
"@blocksuite/icons": "2.0.23",
"@blocksuite/store": "0.5.0-20230320164115-e612d17",
"@blocksuite/store": "0.5.0-20230323032255-e75ed32",
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",

View File

@@ -10,5 +10,6 @@ const config = {
enableDebugPage: Boolean(
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
),
enableSubpage: Boolean(process.env.ENABLE_SUBPAGE),
};
export default config;

View File

@@ -21,7 +21,7 @@ import {
} from '@mui/material';
import { useAtomValue } from 'jotai';
import type React from 'react';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import {
@@ -87,9 +87,13 @@ type PageListProps = {
};
const filter = {
all: (pageMeta: PageMeta) => !pageMeta.trash,
trash: (pageMeta: PageMeta) => pageMeta.trash,
favorite: (pageMeta: PageMeta) => pageMeta.favorite,
all: (pageMeta: PageMeta, allMetas: PageMeta[]) => !pageMeta.trash,
trash: (pageMeta: PageMeta, allMetas: PageMeta[]) => {
const parentMeta = allMetas.find(m => m.subpageIds?.includes(pageMeta.id));
return !parentMeta?.trash && pageMeta.trash;
},
favorite: (pageMeta: PageMeta, allMetas: PageMeta[]) =>
pageMeta.favorite && !pageMeta.trash,
};
export const PageList: React.FC<PageListProps> = ({
@@ -106,9 +110,26 @@ export const PageList: React.FC<PageListProps> = ({
const isTrash = listType === 'trash';
const record = useAtomValue(workspacePreferredModeAtom);
const list = useMemo(
() => pageList.filter(filter[listType ?? 'all']),
() =>
pageList.filter(pageMeta =>
filter[listType ?? 'all'](pageMeta, pageList)
),
[pageList, listType]
);
const restorePage = useCallback(
(pageMeta: PageMeta, allMetas: PageMeta[]) => {
helper.setPageMeta(pageMeta.id, {
trash: false,
});
allMetas
.filter(m => pageMeta?.subpageIds.includes(m.id))
.forEach(m => {
restorePage(m, allMetas);
});
},
[helper]
);
if (list.length === 0) {
return <Empty listType={listType} />;
}
@@ -194,9 +215,7 @@ export const PageList: React.FC<PageListProps> = ({
blockSuiteWorkspace.removePage(pageId);
}}
onRestorePage={() => {
helper.setPageMeta(pageMeta.id, {
trash: false,
});
restorePage(pageMeta, pageList);
}}
onOpenPage={pageId => {
onClickPage(pageId, false);

View File

@@ -1,5 +1,6 @@
import { MuiCollapse } from '@affine/component';
import { IconButton } from '@affine/component';
import { config } from '@affine/env';
import { useTranslation } from '@affine/i18n';
import {
ArrowDownSmallIcon,
@@ -20,6 +21,7 @@ import { useSidebarStatus } from '../../../hooks/affine/use-sidebar-status';
import { usePageMeta } from '../../../hooks/use-page-meta';
import type { RemWorkspace } from '../../../shared';
import { SidebarSwitch } from '../../affine/sidebar-switch';
import { Pivot } from './pivot';
import {
StyledLink,
StyledListItem,
@@ -168,6 +170,15 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
<span data-testid="all-pages">{t('All pages')}</span>
</StyledListItem>
</Link>
{config.enableSubpage && !!currentWorkspace && (
<Pivot
currentWorkspace={currentWorkspace}
openPage={openPage}
allMetas={pageMeta}
/>
)}
<StyledListItem
active={
currentPath ===

View File

@@ -0,0 +1,232 @@
import { IconButton, MuiCollapse, TreeView } from '@affine/component';
import { DebugLogger } from '@affine/debug';
import { ArrowDownSmallIcon, FolderIcon } 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 { StyledListItem } from '../style';
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}
/>
);
};
export const Pivot = ({
currentWorkspace,
openPage,
allMetas,
}: {
currentWorkspace: RemWorkspace;
openPage: (pageId: string) => void;
allMetas: PageMeta[];
}) => {
const [showPivot, setShowPivot] = useState(true);
return (
<>
<StyledListItem>
<FolderIcon />
Pivot
<IconButton
darker={true}
onClick={useCallback(() => {
setShowPivot(!showPivot);
}, [showPivot])}
>
<ArrowDownSmallIcon
style={{
transform: `rotate(${showPivot ? '180' : '0'}deg)`,
}}
/>
</IconButton>
</StyledListItem>
<MuiCollapse
in={showPivot}
style={{
maxHeight: 300,
paddingLeft: '12px',
overflowY: 'auto',
}}
>
<PivotInternal
currentWorkspace={currentWorkspace}
openPage={openPage}
allMetas={allMetas}
/>
</MuiCollapse>
</>
);
};
export default Pivot;

View File

@@ -0,0 +1,69 @@
import { IconButton } from '@affine/component';
import {
ArrowDownSmallIcon,
DeleteTemporarilyIcon,
PlusIcon,
} from '@blocksuite/icons';
import { useRouter } from 'next/router';
import { StyledCollapsedButton, StyledPivotItem } from './styles';
import type { TreeNode } from './types';
export const TreeNodeRender: TreeNode['render'] = (
node,
{ onAdd, onDelete, collapsed, setCollapsed },
extendProps
) => {
const { openPage } = extendProps as { openPage: (pageId: string) => void };
const router = useRouter();
const active = router.query.pageId === node.id;
return (
<StyledPivotItem
onClick={() => {
if (active) {
return;
}
openPage(node.id);
}}
active={active}
>
<StyledCollapsedButton
show={!!node.children?.length}
onClick={e => {
e.stopPropagation();
setCollapsed(!collapsed);
}}
size="small"
>
<ArrowDownSmallIcon
style={{
transform: `rotate(${collapsed ? '0' : '180'}deg)`,
}}
/>
</StyledCollapsedButton>
<span>{node.title || 'Untitled'}</span>
<IconButton
onClick={e => {
e.stopPropagation();
onAdd();
}}
size="small"
className="pivot-item-button"
>
<PlusIcon />
</IconButton>
<IconButton
onClick={e => {
e.stopPropagation();
onDelete();
}}
size="small"
className="pivot-item-button"
>
<DeleteTemporarilyIcon />
</IconButton>
</StyledPivotItem>
);
};

View File

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

View File

@@ -0,0 +1,47 @@
import {
displayFlex,
IconButton,
styled,
textEllipsis,
} from '@affine/component';
export const StyledPivotItem = styled('div')<{ active: boolean }>(
({ active, theme }) => {
return {
width: '100%',
height: '32px',
...displayFlex('flex-start', 'center'),
paddingLeft: '24px',
position: 'relative',
color: active ? theme.colors.primaryColor : theme.colors.textColor,
cursor: 'pointer',
span: {
flexGrow: '1',
...textEllipsis(1),
},
'.pivot-item-button': {
display: 'none',
},
':hover': {
'.pivot-item-button': {
display: 'flex',
},
},
};
}
);
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

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

View File

@@ -0,0 +1,43 @@
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, renderProps),
};
// @ts-ignore
returnedMetas.push(returnedMeta);
return returnedMetas;
}, []);
};
return helper(rootMetas);
};

View File

@@ -9,9 +9,9 @@ export function useBlockSuiteWorkspaceHelper(
) {
return useMemo(
() => ({
createPage: (pageId: string, title?: string): Page => {
createPage: (pageId: string, parentId?: string): Page => {
assertExists(blockSuiteWorkspace);
return blockSuiteWorkspace.createPage(pageId);
return blockSuiteWorkspace.createPage(pageId, parentId);
},
}),
[blockSuiteWorkspace]

View File

@@ -22,7 +22,6 @@ declare global {
callback: Set<() => void>;
};
}
if (!globalThis.featureFlag) {
globalThis.featureFlag = featureFlag;
}

View File

@@ -6,6 +6,7 @@ import type { BlockSuiteWorkspace } from '../shared';
declare module '@blocksuite/store' {
interface PageMeta {
favorite?: boolean;
subpageIds: string[];
trash?: boolean;
trashDate?: number;
// whether to create the page with the default template
@@ -45,6 +46,12 @@ export function usePageMetaHelper(blockSuiteWorkspace: BlockSuiteWorkspace) {
setPageMeta: (pageId: string, pageMeta: Partial<PageMeta>) => {
blockSuiteWorkspace.meta.setPageMeta(pageId, pageMeta);
},
getPageMeta: (pageId: string) => {
return blockSuiteWorkspace.meta.getPageMeta(pageId);
},
shiftPageMeta: (pageId: string, index: number) => {
return blockSuiteWorkspace.meta.shiftPageMeta(pageId, index);
},
}),
[blockSuiteWorkspace]
);