mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat: support subpage (#1663)
This commit is contained in:
@@ -15,3 +15,4 @@ PREFETCH_WORKSPACE=1
|
||||
ENABLE_BC_PROVIDER=1
|
||||
EXPOSE_INTERNAL=1
|
||||
ENABLE_DEBUG_PAGE=
|
||||
ENABLE_SUBPAGE=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Pivot';
|
||||
export * from './types';
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Node } from '@affine/component';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
export type TreeNode = Node<PageMeta>;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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]
|
||||
|
||||
@@ -22,7 +22,6 @@ declare global {
|
||||
callback: Set<() => void>;
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.featureFlag) {
|
||||
globalThis.featureFlag = featureFlag;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user