feat: new collections (#4530)

Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
3720
2023-10-27 17:06:59 +08:00
committed by GitHub
parent 9fc0152cb1
commit ef8024c657
133 changed files with 8382 additions and 3743 deletions

View File

@@ -11,11 +11,9 @@ import { useCurrentUser } from '../../hooks/affine/use-current-user';
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
import { useWorkspace } from '../../hooks/use-workspace';
import {
BlockSuitePageList,
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
WorkspaceHeader,
} from '../shared';
const LoginCard = lazy(() =>
@@ -27,7 +25,6 @@ const LoginCard = lazy(() =>
export const UI = {
Provider,
LoginCard,
Header: WorkspaceHeader,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
@@ -61,16 +58,6 @@ export const UI = {
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: ({
currentWorkspaceId,
onTransformWorkspace,

View File

@@ -29,11 +29,9 @@ import { useCallback } from 'react';
import { setPageModeAtom } from '../../atoms';
import {
BlockSuitePageList,
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
WorkspaceHeader,
} from '../shared';
const logger = new DebugLogger('use-create-first-workspace');
@@ -85,7 +83,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
},
CRUD,
UI: {
Header: WorkspaceHeader,
Provider,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentWorkspaceId);
@@ -105,16 +102,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: ({
currentWorkspaceId,
onTransformWorkspace,

View File

@@ -5,13 +5,10 @@ import { initEmptyPage } from '@toeverything/infra/blocksuite';
import { useCallback } from 'react';
import { useWorkspace } from '../../hooks/use-workspace';
import { BlockSuitePageList, PageDetailEditor, Provider } from '../shared';
import { PageDetailEditor, Provider } from '../shared';
export const UI = {
Provider,
Header: () => {
return null;
},
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
@@ -29,16 +26,6 @@ export const UI = {
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: () => {
throw new Error('Not implemented');
},

View File

@@ -14,22 +14,8 @@ export const NewWorkspaceSettingDetail = lazy(() =>
)
);
export const BlockSuitePageList = lazy(() =>
import('../components/blocksuite/block-suite-page-list').then(
({ BlockSuitePageList }) => ({
default: BlockSuitePageList,
})
)
);
export const PageDetailEditor = lazy(() =>
import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({
default: PageDetailEditor,
}))
);
export const WorkspaceHeader = lazy(() =>
import('../components/workspace-header').then(({ WorkspaceHeader }) => ({
default: WorkspaceHeader,
}))
);

View File

@@ -0,0 +1,210 @@
import type { CollectionsCRUDAtom } from '@affine/component/page-list';
import type { Collection, DeprecatedCollection } from '@affine/env/filter';
import { DisposableGroup } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { currentWorkspaceAtom } from '@toeverything/infra/atom';
import { type DBSchema, openDB } from 'idb';
import { atom } from 'jotai';
import { atomWithObservable } from 'jotai/utils';
import { Observable } from 'rxjs';
import { getUserSetting } from '../utils/user-setting';
import { getWorkspaceSetting } from '../utils/workspace-setting';
import { sessionAtom } from './cloud-user';
/**
* @deprecated
*/
export interface PageCollectionDBV1 extends DBSchema {
view: {
key: DeprecatedCollection['id'];
value: DeprecatedCollection;
};
}
/**
* @deprecated
*/
export interface StorageCRUD<Value> {
get: (key: string) => Promise<Value | null>;
set: (key: string, value: Value) => Promise<string>;
delete: (key: string) => Promise<void>;
list: () => Promise<string[]>;
}
/**
* @deprecated
*/
const collectionDBAtom = atom(
openDB<PageCollectionDBV1>('page-view', 1, {
upgrade(database) {
database.createObjectStore('view', {
keyPath: 'id',
});
},
})
);
/**
* @deprecated
*/
const localCollectionCRUDAtom = atom(get => ({
get: async (key: string) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view').objectStore('view');
return (await t.get(key)) ?? null;
},
set: async (key: string, value: DeprecatedCollection) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view', 'readwrite').objectStore('view');
await t.put(value);
return key;
},
delete: async (key: string) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view', 'readwrite').objectStore('view');
await t.delete(key);
},
list: async () => {
const db = await get(collectionDBAtom);
const t = db.transaction('view').objectStore('view');
return t.getAllKeys();
},
}));
/**
* @deprecated
*/
const getCollections = async (
storage: StorageCRUD<DeprecatedCollection>
): Promise<DeprecatedCollection[]> => {
return storage
.list()
.then(async keys => {
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
v.filter((v): v is DeprecatedCollection => v !== null)
);
})
.catch(error => {
console.error('Failed to load collections', error);
return [];
});
};
type BaseCollectionsDataType = {
loading: boolean;
collections: Collection[];
};
export const pageCollectionBaseAtom =
atomWithObservable<BaseCollectionsDataType>(
get => {
const currentWorkspacePromise = get(currentWorkspaceAtom);
const session = get(sessionAtom);
const userId = session?.data?.user.id ?? null;
const migrateCollectionsFromIdbData = async (
workspace: Workspace
): Promise<Collection[]> => {
workspace.awarenessStore.awareness.emit('change log');
const localCRUD = get(localCollectionCRUDAtom);
const collections = await getCollections(localCRUD);
const result = collections.filter(v => v.workspaceId === workspace.id);
Promise.all(
result.map(collection => {
return localCRUD.delete(collection.id);
})
).catch(error => {
console.error('Failed to delete collections from indexeddb', error);
});
return result.map(v => {
return {
id: v.id,
name: v.name,
mode: 'rule',
filterList: v.filterList,
allowList: v.allowList ?? [],
pages: [],
};
});
};
const migrateCollectionsFromUserData = async (
workspace: Workspace
): Promise<Collection[]> => {
if (userId == null) {
return [];
}
const userSetting = getUserSetting(workspace, userId);
await userSetting.loaded;
const view = userSetting.view;
if (view) {
const collections: DeprecatedCollection[] = [...view.values()];
//delete collections
view.clear();
return collections.map(v => {
return {
id: v.id,
name: v.name,
mode: 'rule',
filterList: v.filterList,
allowList: v.allowList ?? [],
pages: [],
};
});
}
return [];
};
return new Observable<BaseCollectionsDataType>(subscriber => {
const group = new DisposableGroup();
currentWorkspacePromise.then(async currentWorkspace => {
const collectionsFromLocal =
await migrateCollectionsFromIdbData(currentWorkspace);
const collectionFromUserSetting =
await migrateCollectionsFromUserData(currentWorkspace);
const workspaceSetting = getWorkspaceSetting(currentWorkspace);
if (collectionsFromLocal.length || collectionFromUserSetting.length) {
// migrate collections
workspaceSetting.addCollection(
...collectionFromUserSetting,
...collectionsFromLocal
);
}
subscriber.next({
loading: false,
collections: workspaceSetting.collections,
});
if (group.disposed) {
return;
}
const fn = () => {
subscriber.next({
loading: false,
collections: workspaceSetting.collections,
});
};
workspaceSetting.collectionsYArray.observe(fn);
group.add(() => {
workspaceSetting.collectionsYArray.unobserve(fn);
});
});
return () => {
group.dispose();
};
});
},
{ initialValue: { loading: true, collections: [] } }
);
export const collectionsCRUDAtom: CollectionsCRUDAtom = atom(get => {
const workspacePromise = get(currentWorkspaceAtom);
return {
addCollection: async (...collections) => {
const workspace = await workspacePromise;
getWorkspaceSetting(workspace).addCollection(...collections);
},
collections: get(pageCollectionBaseAtom).collections,
updateCollection: async (id, updater) => {
const workspace = await workspacePromise;
getWorkspaceSetting(workspace).updateCollection(id, updater);
},
deleteCollection: async (info, ...ids) => {
const workspace = await workspacePromise;
getWorkspaceSetting(workspace).deleteCollection(info, ...ids);
},
};
});

View File

@@ -2,12 +2,12 @@ import { atom } from 'jotai';
export type TrashModal = {
open: boolean;
pageId: string;
pageTitle: string;
pageIds: string[];
pageTitles: string[];
};
export const trashModalAtom = atom<TrashModal>({
open: false,
pageId: '',
pageTitle: '',
pageIds: [],
pageTitles: [],
});

View File

@@ -70,8 +70,8 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
const handleOpenTrashModal = useCallback(() => {
setTrashModal({
open: true,
pageId,
pageTitle: pageMeta.title,
pageIds: [pageId],
pageTitles: [pageMeta.title],
});
}, [pageId, pageMeta.title, setTrashModal]);

View File

@@ -1,297 +0,0 @@
import { Empty } from '@affine/component';
import type { ListData, TrashListData } from '@affine/component/page-list';
import { PageList, PageListTrashView } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import { type PageMeta, type Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
import { useAtom, useAtomValue } from 'jotai';
import { Suspense, useCallback, useMemo } from 'react';
import { allPageModeSelectAtom } from '../../../atoms';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
import type { BlockSuiteWorkspace } from '../../../shared';
import { toast } from '../../../utils';
import { filterPage } from '../../../utils/filter';
import { currentCollectionsAtom } from '../../../utils/user-setting';
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
import { usePageHelper } from './utils';
export interface BlockSuitePageListProps {
blockSuiteWorkspace: BlockSuiteWorkspace;
listType: 'all' | 'trash' | 'shared' | 'public';
isPublic?: boolean;
onOpenPage: (pageId: string, newTab?: boolean) => void;
collection?: Collection;
}
const filter = {
all: (pageMeta: PageMeta) => !pageMeta.trash,
public: (pageMeta: PageMeta) => !pageMeta.trash,
trash: (pageMeta: PageMeta, allMetas: PageMeta[]) => {
const parentMeta = allMetas.find(m => m.subpageIds?.includes(pageMeta.id));
return !parentMeta?.trash && pageMeta.trash;
},
shared: (pageMeta: PageMeta) => pageMeta.isPublic && !pageMeta.trash,
};
interface PagePreviewInnerProps {
workspace: Workspace;
pageId: string;
}
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
const page = useBlockSuiteWorkspacePage(workspace, pageId);
assertExists(page);
const previewAtom = useBlockSuitePagePreview(page);
const preview = useAtomValue(previewAtom);
return preview;
};
interface PagePreviewProps {
workspace: Workspace;
pageId: string;
}
const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
return (
<Suspense>
<PagePreviewInner workspace={workspace} pageId={pageId} />
</Suspense>
);
};
interface PageListEmptyProps {
createPage?: ReturnType<typeof usePageHelper>['createPage'];
listType: BlockSuitePageListProps['listType'];
}
const PageListEmpty = (props: PageListEmptyProps) => {
const { listType, createPage } = props;
const t = useAFFiNEI18N();
const onCreatePage = useCallback(() => {
createPage?.();
}, [createPage]);
const getEmptyDescription = () => {
if (listType === 'all') {
const createNewPageButton = (
<button className={emptyDescButton} onClick={onCreatePage}>
New Page
</button>
);
if (environment.isDesktop) {
const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N';
return (
<Trans i18nKey="emptyAllPagesClient">
Click on the {createNewPageButton} button Or press
<kbd className={emptyDescKbd}>{{ shortcut } as any}</kbd> to create
your first page.
</Trans>
);
}
return (
<Trans i18nKey="emptyAllPages">
Click on the
{createNewPageButton}
button to create your first page.
</Trans>
);
}
if (listType === 'trash') {
return t['emptyTrash']();
}
if (listType === 'shared') {
return t['emptySharedPages']();
}
return;
};
return (
<div className={pageListEmptyStyle}>
<Empty
title={t['com.affine.emptyDesc']()}
description={getEmptyDescription()}
/>
</div>
);
};
export const BlockSuitePageList = ({
blockSuiteWorkspace,
onOpenPage,
listType,
isPublic = false,
collection,
}: BlockSuitePageListProps) => {
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
const {
toggleFavorite,
restoreFromTrash,
permanentlyDeletePage,
cancelPublicPage,
} = useBlockSuiteMetaHelper(blockSuiteWorkspace);
const [filterMode] = useAtom(allPageModeSelectAtom);
const { createPage, createEdgeless, importFile, isPreferredEdgeless } =
usePageHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N();
const getPageInfo = useGetPageInfoById(blockSuiteWorkspace);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const tagOptionMap = useMemo(
() =>
Object.fromEntries(
(blockSuiteWorkspace.meta.properties.tags?.options ?? []).map(v => [
v.id,
v,
])
),
[blockSuiteWorkspace.meta.properties.tags?.options]
);
const list = useMemo(
() =>
pageMetas
.filter(pageMeta => {
if (filterMode === 'all') {
return true;
}
if (filterMode === 'edgeless') {
return isPreferredEdgeless(pageMeta.id);
}
if (filterMode === 'page') {
return !isPreferredEdgeless(pageMeta.id);
}
console.error('unknown filter mode', pageMeta, filterMode);
return true;
})
.filter(pageMeta => {
if (!filter[listType](pageMeta, pageMetas)) {
return false;
}
if (!collection) {
return true;
}
return filterPage(collection, pageMeta);
}),
[pageMetas, filterMode, isPreferredEdgeless, listType, collection]
);
if (listType === 'trash') {
const pageList: TrashListData[] = list.map(pageMeta => {
return {
icon: isPreferredEdgeless(pageMeta.id) ? (
<EdgelessIcon />
) : (
<PageIcon />
),
pageId: pageMeta.id,
title: pageMeta.title,
preview: (
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
),
createDate: new Date(pageMeta.createDate),
trashDate: pageMeta.trashDate
? new Date(pageMeta.trashDate)
: undefined,
onClickPage: () => onOpenPage(pageMeta.id),
onClickRestore: () => {
restoreFromTrash(pageMeta.id);
},
onRestorePage: () => {
restoreFromTrash(pageMeta.id);
toast(
t['com.affine.toastMessage.restored']({
title: pageMeta.title || 'Untitled',
})
);
},
onPermanentlyDeletePage: () => {
permanentlyDeletePage(pageMeta.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
},
};
});
return (
<PageListTrashView
list={pageList}
fallback={<PageListEmpty listType={listType} />}
/>
);
}
const pageList: ListData[] = list.map(pageMeta => {
const page = blockSuiteWorkspace.getPage(pageMeta.id);
return {
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
pageId: pageMeta.id,
title: pageMeta.title,
preview: (
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
),
tags:
page?.meta.tags?.map(id => tagOptionMap[id]).filter(v => v != null) ??
[],
favorite: !!pageMeta.favorite,
isPublicPage: !!pageMeta.isPublic,
createDate: new Date(pageMeta.createDate),
updatedDate: new Date(pageMeta.updatedDate ?? pageMeta.createDate),
onClickPage: () => onOpenPage(pageMeta.id),
onOpenPageInNewTab: () => onOpenPage(pageMeta.id, true),
onClickRestore: () => {
restoreFromTrash(pageMeta.id);
},
removeToTrash: () =>
setTrashModal({
open: true,
pageId: pageMeta.id,
pageTitle: pageMeta.title,
}),
onRestorePage: () => {
restoreFromTrash(pageMeta.id);
toast(
t['com.affine.toastMessage.restored']({
title: pageMeta.title || 'Untitled',
})
);
},
bookmarkPage: () => {
const status = pageMeta.favorite;
toggleFavorite(pageMeta.id);
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
},
onDisablePublicSharing: () => {
cancelPublicPage(pageMeta.id);
toast('Successfully disabled', {
portal: document.body,
});
},
};
});
return (
<PageList
collectionsAtom={currentCollectionsAtom}
propertiesMeta={blockSuiteWorkspace.meta.properties}
getPageInfo={getPageInfo}
onCreateNewPage={createPage}
onCreateNewEdgeless={createEdgeless}
onImportFile={importFile}
isPublicWorkspace={isPublic}
list={pageList}
fallback={<PageListEmpty createPage={createPage} listType={listType} />}
/>
);
};

View File

@@ -1,18 +1,7 @@
import { style } from '@vanilla-extract/css';
export const filterContainerStyle = style({
padding: '12px',
padding: '0 16px',
display: 'flex',
position: 'relative',
'::after': {
content: '""',
display: 'block',
width: '100%',
height: '1px',
background: 'var(--affine-border-color)',
position: 'absolute',
bottom: 0,
left: 0,
margin: '0 1px',
},
});

View File

@@ -25,17 +25,17 @@ import {
} from '@toeverything/infra/command';
import { atom, useAtomValue } from 'jotai';
import groupBy from 'lodash/groupBy';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import {
openQuickSearchModalAtom,
pageSettingsAtom,
recentPageIdsBaseAtom,
} from '../../../atoms';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../../../shared';
import { currentCollectionsAtom } from '../../../utils/user-setting';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import type { CMDKCommand, CommandContext } from './types';
@@ -295,7 +295,7 @@ export const collectionToCommand = (
collection: Collection,
store: ReturnType<typeof getCurrentStore>,
navigationHelper: ReturnType<typeof useNavigateHelper>,
selectCollection: ReturnType<typeof useCollectionManager>['selectCollection'],
selectCollection: (id: string) => void,
t: ReturnType<typeof useAFFiNEI18N>
): CMDKCommand => {
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
@@ -329,14 +329,18 @@ export const collectionToCommand = (
export const useCollectionsCommands = () => {
// todo: considering collections for searching pages
const { savedCollections, selectCollection } = useCollectionManager(
currentCollectionsAtom
);
const { savedCollections } = useCollectionManager(collectionsCRUDAtom);
const store = getCurrentStore();
const query = useAtomValue(cmdkQueryAtom);
const navigationHelper = useNavigateHelper();
const t = useAFFiNEI18N();
const [workspace] = useCurrentWorkspace();
const selectCollection = useCallback(
(id: string) => {
navigationHelper.jumpToCollection(workspace.id, id);
},
[navigationHelper, workspace.id]
);
return useMemo(() => {
let results: CMDKCommand[] = [];
if (query.trim() === '') {

View File

@@ -6,7 +6,7 @@ import {
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
import clsx from 'clsx';
import { type Atom, useAtomValue } from 'jotai';
import type { ReactElement } from 'react';
import type { ReactNode } from 'react';
import { forwardRef, useRef } from 'react';
import * as style from './style.css';
@@ -14,17 +14,18 @@ import { TopTip } from './top-tip';
import { WindowsAppControls } from './windows-app-controls';
interface HeaderPros {
left?: ReactElement;
right?: ReactElement;
center?: ReactElement;
left?: ReactNode;
right?: ReactNode;
center?: ReactNode;
mainContainerAtom: Atom<HTMLDivElement | null>;
bottomBorder?: boolean;
}
// The Header component is used to solve the following problems
// 1. Manage layout issues independently of page or business logic
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
{ left, center, right, mainContainerAtom },
{ left, center, right, mainContainerAtom, bottomBorder },
ref
) {
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
@@ -51,7 +52,7 @@ export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
<>
<TopTip />
<div
className={style.header}
className={clsx(style.header, bottomBorder && style.bottomBorder)}
// data-has-warning={showWarning}
data-open={open}
data-sidebar-floating={appSidebarFloating}

View File

@@ -8,7 +8,6 @@ export const header = style({
padding: '0 16px',
minHeight: '52px',
background: 'var(--affine-background-primary-color)',
borderBottom: '1px solid var(--affine-border-color)',
zIndex: 2,
selectors: {
'&[data-sidebar-floating="false"]': {
@@ -25,6 +24,10 @@ export const header = style({
},
} as ComplexStyleRule);
export const bottomBorder = style({
borderBottom: '1px solid var(--affine-border-color)',
});
export const headerItem = style({
minHeight: '32px',
display: 'flex',

View File

@@ -1,41 +1,41 @@
import {
EditCollectionModal,
createEmptyCollection,
useCollectionManager,
useEditCollectionName,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { PlusIcon } from '@blocksuite/icons';
import type { Workspace } from '@blocksuite/store';
import { IconButton } from '@toeverything/components/button';
import { nanoid } from 'nanoid';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
import { currentCollectionsAtom } from '../../../../utils/user-setting';
import { collectionsCRUDAtom } from '../../../../atoms/collections';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
type AddCollectionButtonProps = {
workspace: Workspace;
};
export const AddCollectionButton = ({
workspace,
}: AddCollectionButtonProps) => {
const getPageInfo = useGetPageInfoById(workspace);
const setting = useCollectionManager(currentCollectionsAtom);
export const AddCollectionButton = () => {
const setting = useCollectionManager(collectionsCRUDAtom);
const t = useAFFiNEI18N();
const [show, showUpdateCollection] = useState(false);
const [defaultCollection, setDefaultCollection] = useState<Collection>();
const { node, open } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const navigateHelper = useNavigateHelper();
const [workspace] = useCurrentWorkspace();
const handleClick = useCallback(() => {
showUpdateCollection(true);
setDefaultCollection({
id: nanoid(),
name: '',
pinned: true,
filterList: [],
workspaceId: workspace.id,
});
}, [showUpdateCollection, workspace.id]);
open('')
.then(name => {
const id = nanoid();
return setting
.createCollection(createEmptyCollection(id, { name }))
.then(() => {
navigateHelper.jumpToCollection(workspace.id, id);
});
})
.catch(err => {
console.error(err);
});
}, [navigateHelper, open, setting, workspace.id]);
return (
<>
<IconButton
@@ -45,16 +45,7 @@ export const AddCollectionButton = ({
>
<PlusIcon />
</IconButton>
<EditCollectionModal
propertiesMeta={workspace.meta.properties}
getPageInfo={getPageInfo}
onConfirm={setting.saveCollection}
open={show}
onOpenChange={showUpdateCollection}
title={t['com.affine.editCollection.saveCollection']()}
init={defaultCollection}
/>
{node}
</>
);
};

View File

@@ -1,40 +1,29 @@
import { MenuItem as CollectionItem } from '@affine/component/app-sidebar';
import { AnimatedCollectionsIcon } from '@affine/component';
import {
EditCollectionModal,
MenuItem as SidebarMenuItem,
MenuLinkItem as SidebarMenuLinkItem,
} from '@affine/component/app-sidebar';
import {
CollectionOperations,
filterPage,
stopPropagation,
useCollectionManager,
useSavedCollections,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { WorkspaceSubPath } from '@affine/env/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteIcon,
FilterIcon,
InformationIcon,
MoreHorizontalIcon,
UnpinIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import { InformationIcon, MoreHorizontalIcon } from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store';
import type { DragEndEvent } from '@dnd-kit/core';
import { useDroppable } from '@dnd-kit/core';
import * as Collapsible from '@radix-ui/react-collapsible';
import { IconButton } from '@toeverything/components/button';
import {
Menu,
MenuIcon,
MenuItem,
type MenuItemProps,
} from '@toeverything/components/menu';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import type { ReactElement } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
import { filterPage } from '../../../../utils/filter';
import { currentCollectionsAtom } from '../../../../utils/user-setting';
import { collectionsCRUDAtom } from '../../../../atoms/collections';
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
import type { CollectionsListProps } from '../index';
import { Page } from './page';
import * as styles from './styles.css';
@@ -51,111 +40,20 @@ export const processCollectionsDrag = (e: DragEndEvent) => {
e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId);
}
};
const CollectionOperations = ({
view,
showUpdateCollection,
setting,
}: {
view: Collection;
showUpdateCollection: () => void;
setting: ReturnType<typeof useCollectionManager>;
}) => {
const t = useAFFiNEI18N();
const actions = useMemo<
Array<
| {
icon: ReactElement;
name: string;
click: () => void;
type?: MenuItemProps['type'];
element?: undefined;
}
| {
element: ReactElement;
}
>
>(
() => [
{
icon: (
<MenuIcon>
<FilterIcon />
</MenuIcon>
),
name: t['Edit Filter'](),
click: showUpdateCollection,
},
{
icon: (
<MenuIcon>
<UnpinIcon />
</MenuIcon>
),
name: t['Unpin'](),
click: () => {
return setting.updateCollection({
...view,
pinned: false,
});
},
},
{
element: <div key="divider" className={styles.menuDividerStyle}></div>,
},
{
icon: (
<MenuIcon>
<DeleteIcon />
</MenuIcon>
),
name: t['Delete'](),
click: () => {
return setting.deleteCollection(view.id);
},
type: 'danger',
},
],
[setting, showUpdateCollection, t, view]
);
return (
<div style={{ minWidth: 150 }}>
{actions.map(action => {
if (action.element) {
return action.element;
}
return (
<MenuItem
data-testid="collection-option"
key={action.name}
type={action.type}
preFix={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</div>
);
};
const CollectionRenderer = ({
collection,
pages,
workspace,
getPageInfo,
info,
}: {
collection: Collection;
pages: PageMeta[];
workspace: Workspace;
getPageInfo: GetPageInfoById;
info: DeleteCollectionInfo;
}) => {
const [collapsed, setCollapsed] = useState(true);
const setting = useCollectionManager(currentCollectionsAtom);
const { jumpToSubPath } = useNavigateHelper();
const clickCollection = useCallback(() => {
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
setting.selectCollection(collection.id);
}, [jumpToSubPath, workspace.id, setting, collection.id]);
const setting = useCollectionManager(collectionsCRUDAtom);
const { setNodeRef, isOver } = useDroppable({
id: `${Collections_DROP_AREA_PREFIX}${collection.id}`,
data: {
@@ -166,19 +64,15 @@ const CollectionRenderer = ({
},
},
});
const config = useAllPageListConfig();
const allPagesMeta = useMemo(
() => Object.fromEntries(pages.map(v => [v.id, v])),
[pages]
);
const [show, showUpdateCollection] = useState(false);
const allowList = useMemo(
() => new Set(collection.allowList),
[collection.allowList]
);
const excludeList = useMemo(
() => new Set(collection.excludeList),
[collection.excludeList]
);
const removeFromAllowList = useCallback(
(id: string) => {
return setting.updateCollection({
@@ -188,57 +82,41 @@ const CollectionRenderer = ({
},
[collection, setting]
);
const addToExcludeList = useCallback(
(id: string) => {
return setting.updateCollection({
...collection,
excludeList: [id, ...(collection.excludeList ?? [])],
});
},
[collection, setting]
);
const pagesToRender = pages.filter(
page => filterPage(collection, page) && !page.trash
);
const location = useLocation();
const currentPath = location.pathname.split('?')[0];
const path = `/workspace/${workspace.id}/collection/${collection.id}`;
return (
<Collapsible.Root open={!collapsed}>
<EditCollectionModal
propertiesMeta={workspace.meta.properties}
getPageInfo={getPageInfo}
init={collection}
onConfirm={setting.saveCollection}
open={show}
onOpenChange={showUpdateCollection}
/>
<CollectionItem
<SidebarMenuLinkItem
data-testid="collection-item"
data-type="collection-list-item"
ref={setNodeRef}
onCollapsedChange={setCollapsed}
active={isOver}
icon={<ViewLayersIcon />}
active={isOver || currentPath === path}
icon={<AnimatedCollectionsIcon closed={isOver} />}
to={path}
postfix={
<Menu
items={
<CollectionOperations
view={collection}
showUpdateCollection={() => showUpdateCollection(true)}
setting={setting}
/>
}
>
<IconButton
data-testid="collection-options"
type="plain"
withoutHoverStyle
<div onClick={stopPropagation}>
<CollectionOperations
info={info}
collection={collection}
setting={setting}
config={config}
>
<MoreHorizontalIcon />
</IconButton>
</Menu>
<IconButton
data-testid="collection-options"
type="plain"
withoutHoverStyle
>
<MoreHorizontalIcon />
</IconButton>
</CollectionOperations>
</div>
}
collapsed={pagesToRender.length > 0 ? collapsed : undefined}
onClick={clickCollection}
>
<div
style={{
@@ -249,7 +127,7 @@ const CollectionRenderer = ({
>
<div>{collection.name}</div>
</div>
</CollectionItem>
</SidebarMenuLinkItem>
<Collapsible.Content className={styles.collapsibleContent}>
<div style={{ marginLeft: 20, marginTop: -4 }}>
{pagesToRender.map(page => {
@@ -257,8 +135,6 @@ const CollectionRenderer = ({
<Page
inAllowList={allowList.has(page.id)}
removeFromAllowList={removeFromAllowList}
inExcludeList={excludeList.has(page.id)}
addToExcludeList={addToExcludeList}
allPageMeta={allPagesMeta}
page={page}
key={page.id}
@@ -271,32 +147,27 @@ const CollectionRenderer = ({
</Collapsible.Root>
);
};
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
export const CollectionsList = ({ workspace, info }: CollectionsListProps) => {
const metas = useBlockSuitePageMeta(workspace);
const { savedCollections } = useSavedCollections(currentCollectionsAtom);
const getPageInfo = useGetPageInfoById(workspace);
const pinedCollections = useMemo(
() => savedCollections.filter(v => v.pinned),
[savedCollections]
);
const { collections } = useSavedCollections(collectionsCRUDAtom);
const t = useAFFiNEI18N();
if (pinedCollections.length === 0) {
if (collections.length === 0) {
return (
<CollectionItem
<SidebarMenuItem
data-testid="slider-bar-collection-null-description"
icon={<InformationIcon />}
disabled
>
<span>{t['Create a collection']()}</span>
</CollectionItem>
</SidebarMenuItem>
);
}
return (
<div data-testid="collections" className={styles.wrapper}>
{pinedCollections.map(view => {
{collections.map(view => {
return (
<CollectionRenderer
getPageInfo={getPageInfo}
info={info}
key={view.id}
collection={view}
pages={metas}

View File

@@ -4,7 +4,6 @@ import {
DeleteIcon,
EdgelessIcon,
FilterMinusIcon,
FilterUndoIcon,
MoreHorizontalIcon,
PageIcon,
} from '@blocksuite/icons';
@@ -32,25 +31,21 @@ import * as styles from './styles.css';
export const PageOperations = ({
page,
inAllowList,
addToExcludeList,
removeFromAllowList,
inExcludeList,
workspace,
}: {
workspace: Workspace;
page: PageMeta;
inAllowList: boolean;
removeFromAllowList: (id: string) => void;
inExcludeList: boolean;
addToExcludeList: (id: string) => void;
}) => {
const t = useAFFiNEI18N();
const { setTrashModal } = useTrashModalHelper(workspace);
const onClickDelete = useCallback(() => {
setTrashModal({
open: true,
pageId: page.id,
pageTitle: page.title,
pageIds: [page.id],
pageTitles: [page.title],
});
}, [page.id, page.title, setTrashModal]);
const actions = useMemo<
@@ -79,24 +74,13 @@ export const PageOperations = ({
name: t['Remove special filter'](),
click: () => removeFromAllowList(page.id),
},
]
: []),
...(!inExcludeList
? [
{
icon: (
<MenuIcon>
<FilterUndoIcon />
</MenuIcon>
element: (
<div key="divider" className={styles.menuDividerStyle}></div>
),
name: t['Exclude from filter'](),
click: () => addToExcludeList(page.id),
},
]
: []),
{
element: <div key="divider" className={styles.menuDividerStyle}></div>,
},
{
icon: (
<MenuIcon>
@@ -108,15 +92,7 @@ export const PageOperations = ({
type: 'danger',
},
],
[
inAllowList,
t,
inExcludeList,
onClickDelete,
removeFromAllowList,
page.id,
addToExcludeList,
]
[inAllowList, t, onClickDelete, removeFromAllowList, page.id]
);
return (
<>
@@ -144,15 +120,11 @@ export const Page = ({
workspace,
allPageMeta,
inAllowList,
inExcludeList,
removeFromAllowList,
addToExcludeList,
}: {
page: PageMeta;
inAllowList: boolean;
removeFromAllowList: (id: string) => void;
inExcludeList: boolean;
addToExcludeList: (id: string) => void;
workspace: Workspace;
allPageMeta: Record<string, PageMeta>;
}) => {
@@ -186,8 +158,6 @@ export const Page = ({
<PageOperations
inAllowList={inAllowList}
removeFromAllowList={removeFromAllowList}
inExcludeList={inExcludeList}
addToExcludeList={addToExcludeList}
page={page}
workspace={workspace}
/>

View File

@@ -1,6 +1,9 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
export const wrapper = style({
display: 'flex',
flexDirection: 'column',
gap: '4px',
userSelect: 'none',
// marginLeft:8,
});

View File

@@ -1,3 +1,4 @@
import type { DeleteCollectionInfo } from '@affine/env/filter';
import type { Workspace } from '@blocksuite/store';
export type FavoriteListProps = {
@@ -6,4 +7,5 @@ export type FavoriteListProps = {
export type CollectionsListProps = {
workspace: Workspace;
info: DeleteCollectionInfo;
};

View File

@@ -1,3 +1,4 @@
import { AnimatedDeleteIcon } from '@affine/component';
import {
AddPageButton,
AppSidebar,
@@ -10,13 +11,10 @@ import {
SidebarContainer,
SidebarScrollableContainer,
} from '@affine/component/app-sidebar';
import { MoveToTrash, useCollectionManager } from '@affine/component/page-list';
import { MoveToTrash } from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteTemporarilyIcon,
FolderIcon,
SettingsIcon,
} from '@blocksuite/icons';
import { FolderIcon, SettingsIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useDroppable } from '@dnd-kit/core';
import { Menu } from '@toeverything/components/menu';
@@ -27,11 +25,12 @@ import { forwardRef, useCallback, useEffect, useMemo } from 'react';
import { openWorkspaceListModalAtom } from '../../atoms';
import { useHistoryAtom } from '../../atoms/history';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/use-shortcut-commands';
import type { AllWorkspace } from '../../shared';
import { currentCollectionsAtom } from '../../utils/user-setting';
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
@@ -101,7 +100,6 @@ export const RootAppSidebar = ({
}: RootAppSidebarProps): ReactElement => {
const currentWorkspaceId = currentWorkspace.id;
const { appSettings } = useAppSettingHelper();
const { backToAll } = useCollectionManager(currentCollectionsAtom);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const t = useAFFiNEI18N();
const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom(
@@ -117,7 +115,7 @@ export const RootAppSidebar = ({
const { trashModal, setTrashModal, handleOnConfirm } =
useTrashModalHelper(blockSuiteWorkspace);
const deletePageTitle = trashModal.pageTitle;
const deletePageTitles = trashModal.pageTitles;
const trashConfirmOpen = trashModal.open;
const onTrashConfirmOpenChange = useCallback(
(open: boolean) => {
@@ -129,6 +127,10 @@ export const RootAppSidebar = ({
[trashModal, setTrashModal]
);
const navigateHelper = useNavigateHelper();
const backToAll = useCallback(() => {
navigateHelper.jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
}, [currentWorkspace.id, navigateHelper]);
// Listen to the "New Page" action from the menu
useEffect(() => {
if (environment.isDesktop) {
@@ -166,6 +168,7 @@ export const RootAppSidebar = ({
setOpenUserWorkspaceList(false);
}, [setOpenUserWorkspaceList]);
useRegisterBlocksuiteEditorCommands(router.back, router.forward);
const userInfo = useDeleteCollectionInfo();
return (
<>
<AppSidebar
@@ -183,7 +186,7 @@ export const RootAppSidebar = ({
open={trashConfirmOpen}
onConfirm={handleOnConfirm}
onOpenChange={onTrashConfirmOpenChange}
title={deletePageTitle}
titles={deletePageTitles}
/>
<SidebarContainer>
<Menu
@@ -243,16 +246,16 @@ export const RootAppSidebar = ({
</CategoryDivider>
<FavoriteList workspace={blockSuiteWorkspace} />
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
<AddCollectionButton workspace={blockSuiteWorkspace} />
<AddCollectionButton />
</CategoryDivider>
<CollectionsList workspace={blockSuiteWorkspace} />
<CollectionsList workspace={blockSuiteWorkspace} info={userInfo} />
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
{/* fixme: remove the following spacer */}
<div style={{ height: '4px' }} />
<RouteMenuLinkItem
ref={trashDroppable.setNodeRef}
isDraggedOver={trashDroppable.isOver}
icon={<DeleteTemporarilyIcon />}
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
currentPath={currentPath}
path={paths.trash(currentWorkspaceId)}
>

View File

@@ -0,0 +1,14 @@
import { style } from '@vanilla-extract/css';
export const trashTitle = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 8px',
fontWeight: 600,
});
export const trashIcon = style({
color: 'var(--affine-icon-color)',
fontSize: 'var(--affine-font-h-5)',
});

View File

@@ -1,43 +1,50 @@
import {
CollectionList,
FilterList,
SaveCollectionButton,
SaveAsCollectionButton,
useCollectionManager,
} from '@affine/component/page-list';
import { Unreachable } from '@affine/env/constant';
import type { Collection } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type {
WorkspaceFlavour,
WorkspaceHeaderProps,
} from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon } from '@blocksuite/icons';
import { useSetAtom } from 'jotai/react';
import { useCallback } from 'react';
import { collectionsCRUDAtom } from '../atoms/collections';
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
import { useGetPageInfoById } from '../hooks/use-get-page-info';
import { useAllPageListConfig } from '../hooks/affine/use-all-page-list-config';
import { useDeleteCollectionInfo } from '../hooks/affine/use-delete-collection-info';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { useWorkspace } from '../hooks/use-workspace';
import { currentCollectionsAtom } from '../utils/user-setting';
import { SharePageModal } from './affine/share-page-modal';
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
import { filterContainerStyle } from './filter-container.css';
import { Header } from './pure/header';
import { PluginHeader } from './pure/plugin-header';
import { WorkspaceModeFilterTab } from './pure/workspace-mode-filter-tab';
import * as styles from './workspace-header.css';
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
const currentWorkspace = useWorkspace(workspaceId);
const setting = useCollectionManager(currentCollectionsAtom);
const navigateHelper = useNavigateHelper();
const setting = useCollectionManager(collectionsCRUDAtom);
const saveToCollection = useCallback(
async (collection: Collection) => {
await setting.saveCollection(collection);
setting.selectCollection(collection.id);
console.log(setting.currentCollection.filterList);
await setting.createCollection({
...collection,
mode: 'rule',
filterList: setting.currentCollection.filterList,
});
navigateHelper.jumpToCollection(workspaceId, collection.id);
},
[setting]
);
const getPageInfoById = useGetPageInfoById(
currentWorkspace.blockSuiteWorkspace
[setting, navigateHelper, workspaceId]
);
if (!setting.isDefault || !setting.currentCollection.filterList.length) {
return null;
@@ -59,16 +66,9 @@ const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
</div>
<div>
{setting.currentCollection.filterList.length > 0 ? (
<SaveCollectionButton
propertiesMeta={
currentWorkspace.blockSuiteWorkspace.meta
.properties as PropertiesMeta
}
getPageInfo={getPageInfoById}
<SaveAsCollectionButton
onConfirm={saveToCollection}
filterList={setting.currentCollection.filterList}
workspaceId={workspaceId}
></SaveCollectionButton>
></SaveAsCollectionButton>
) : null}
</div>
</div>
@@ -78,14 +78,17 @@ const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
export function WorkspaceHeader({
currentWorkspaceId,
currentEntry,
rightSlot,
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
const setAppHeader = useSetAtom(appHeaderAtom);
const currentWorkspace = useWorkspace(currentWorkspaceId);
const setting = useCollectionManager(currentCollectionsAtom);
const getPageInfoById = useGetPageInfoById(
currentWorkspace.blockSuiteWorkspace
);
const workspace = currentWorkspace.blockSuiteWorkspace;
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const userInfo = useDeleteCollectionInfo();
const t = useAFFiNEI18N();
// route in all page
if (
@@ -99,25 +102,24 @@ export function WorkspaceHeader({
ref={setAppHeader}
left={
<CollectionList
userInfo={userInfo}
allPageListConfig={config}
setting={setting}
getPageInfo={getPageInfoById}
propertiesMeta={
currentWorkspace.blockSuiteWorkspace.meta.properties
}
propertiesMeta={workspace.meta.properties}
/>
}
right={rightSlot}
center={<WorkspaceModeFilterTab />}
/>
{<FilterContainer workspaceId={currentWorkspaceId} />}
<FilterContainer workspaceId={currentWorkspaceId} />
</>
);
}
// route in shared or trash
// route in shared
if (
'subPath' in currentEntry &&
(currentEntry.subPath === WorkspaceSubPath.SHARED ||
currentEntry.subPath === WorkspaceSubPath.TRASH)
currentEntry.subPath === WorkspaceSubPath.SHARED
) {
return (
<Header
@@ -128,11 +130,28 @@ export function WorkspaceHeader({
);
}
// route in trash
if (
'subPath' in currentEntry &&
currentEntry.subPath === WorkspaceSubPath.TRASH
) {
return (
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
left={
<div className={styles.trashTitle}>
<DeleteIcon className={styles.trashIcon} />
{t['com.affine.workspaceSubPath.trash']()}
</div>
}
/>
);
}
// route in edit page
if ('pageId' in currentEntry) {
const currentPage = currentWorkspace.blockSuiteWorkspace.getPage(
currentEntry.pageId
);
const currentPage = workspace.getPage(currentEntry.pageId);
const sharePageModal = currentPage ? (
<SharePageModal workspace={currentWorkspace} page={currentPage} />
) : null;
@@ -152,6 +171,7 @@ export function WorkspaceHeader({
<PluginHeader />
</div>
}
bottomBorder
/>
);
}

View File

@@ -0,0 +1,63 @@
import { toast } from '@affine/component';
import {
type AllPageListConfig,
FavoriteTag,
} from '@affine/component/page-list';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMeta } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback, useMemo } from 'react';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { useCurrentWorkspace } from '../current/use-current-workspace';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
export const useAllPageListConfig = () => {
const [currentWorkspace] = useCurrentWorkspace();
const workspace = currentWorkspace.blockSuiteWorkspace;
const pageMetas = useBlockSuitePageMeta(workspace);
const { isPreferredEdgeless } = usePageHelper(workspace);
const pageMap = useMemo(
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
[pageMetas]
);
const { toggleFavorite } = useBlockSuiteMetaHelper(
currentWorkspace.blockSuiteWorkspace
);
const t = useAFFiNEI18N();
const onToggleFavoritePage = useCallback(
(page: PageMeta) => {
const status = page.favorite;
toggleFavorite(page.id);
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
},
[t, toggleFavorite]
);
return useMemo<AllPageListConfig>(() => {
return {
allPages: pageMetas,
isEdgeless: isPreferredEdgeless,
workspace: currentWorkspace.blockSuiteWorkspace,
getPage: id => pageMap[id],
favoriteRender: page => {
return (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={() => onToggleFavoritePage(page)}
active={!!page.favorite}
/>
);
},
};
}, [
currentWorkspace.blockSuiteWorkspace,
isPreferredEdgeless,
pageMetas,
pageMap,
onToggleFavoritePage,
]);
};

View File

@@ -8,6 +8,7 @@ import { useCallback } from 'react';
import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode';
import type { BlockSuiteWorkspace } from '../../shared';
import { getWorkspaceSetting } from '../../utils/workspace-setting';
import { useReferenceLinkHelper } from './use-reference-link-helper';
export function useBlockSuiteMetaHelper(
@@ -82,8 +83,9 @@ export function useBlockSuiteMetaHelper(
trashRelate: isRoot ? parentMeta?.id : undefined,
});
setPageReadonly(pageId, true);
getWorkspaceSetting(blockSuiteWorkspace).deletePages([pageId]);
},
[getPageMeta, metas, setPageMeta, setPageReadonly]
[blockSuiteWorkspace, getPageMeta, metas, setPageMeta, setPageReadonly]
);
const restoreFromTrash = useCallback(

View File

@@ -0,0 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useMemo } from 'react';
export const useDeleteCollectionInfo = () => {
const user = useSession().data?.user;
return useMemo(
() => (user ? { userName: user.name ?? '', userId: user.id } : null),
[user]
);
};

View File

@@ -35,8 +35,8 @@ export function useRegisterBlocksuiteEditorCommands(
const onClickDelete = useCallback(() => {
setTrashModal({
open: true,
pageId: pageId,
pageTitle: pageMeta.title,
pageIds: [pageId],
pageTitles: [pageMeta.title],
});
}, [pageId, pageMeta.title, setTrashModal]);

View File

@@ -10,14 +10,16 @@ import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
export function useTrashModalHelper(blocksuiteWorkspace: Workspace) {
const t = useAFFiNEI18N();
const [trashModal, setTrashModal] = useAtom(trashModalAtom);
const { pageId } = trashModal;
const { pageIds } = trashModal;
const { removeToTrash } = useBlockSuiteMetaHelper(blocksuiteWorkspace);
const handleOnConfirm = useCallback(() => {
removeToTrash(pageId);
pageIds.forEach(pageId => {
removeToTrash(pageId);
});
toast(t['com.affine.toastMessage.movedTrash']());
setTrashModal({ ...trashModal, open: false });
}, [pageId, removeToTrash, setTrashModal, t, trashModal]);
}, [pageIds, removeToTrash, setTrashModal, t, trashModal]);
return {
trashModal,

View File

@@ -12,6 +12,7 @@ export enum RouteLogic {
PUSH = 'push',
}
// todo: add a name -> path helper in the results
export function useNavigateHelper() {
const location = useLocation();
const navigate = useNavigate();
@@ -28,6 +29,18 @@ export function useNavigateHelper() {
},
[navigate]
);
const jumpToCollection = useCallback(
(
workspaceId: string,
collectionId: string,
logic: RouteLogic = RouteLogic.PUSH
) => {
return navigate(`/workspace/${workspaceId}/collection/${collectionId}`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToPublicWorkspacePage = useCallback(
(
workspaceId: string,
@@ -116,6 +129,7 @@ export function useNavigateHelper() {
openPage,
jumpToExpired,
jumpToSignIn,
jumpToCollection,
}),
[
jumpTo404,
@@ -126,6 +140,7 @@ export function useNavigateHelper() {
jumpToSignIn,
jumpToSubPath,
openPage,
jumpToCollection,
]
);
}

View File

@@ -1,11 +1,12 @@
import { Content, displayFlex } from '@affine/component';
import {
AppSidebarFallback,
appSidebarResizingAtom,
} from '@affine/component/app-sidebar';
import { BlockHubWrapper } from '@affine/component/block-hub';
import type { DraggableTitleCellData } from '@affine/component/page-list';
import { StyledTitleLink } from '@affine/component/page-list';
import {
type DraggableTitleCellData,
PageListDragOverlay,
} from '@affine/component/page-list';
import {
MainContainer,
ToolContainer,
@@ -197,12 +198,9 @@ export const WorkspaceLayoutInner = ({
const resizing = useAtomValue(appSidebarResizingAtom);
const sensors = useSensors(
// Delay 10ms after mousedown
// Otherwise clicks would be intercepted
useSensor(MouseSensor, {
activationConstraint: {
delay: 500,
tolerance: 10,
distance: 10,
},
})
);
@@ -288,34 +286,18 @@ export const WorkspaceLayoutInner = ({
};
function PageListTitleCellDragOverlay() {
const { active } = useDndContext();
const { active, over } = useDndContext();
const renderChildren = useCallback(
({ icon, pageTitle }: DraggableTitleCellData) => {
({ pageTitle }: DraggableTitleCellData) => {
return (
<StyledTitleLink>
{icon}
<Content ellipsis={true} color="inherit">
{pageTitle}
</Content>
</StyledTitleLink>
<PageListDragOverlay over={!!over}>{pageTitle}</PageListDragOverlay>
);
},
[]
[over]
);
return (
<DragOverlay
style={{
zIndex: 1001,
backgroundColor: 'var(--affine-black-10)',
padding: '0 30px',
cursor: 'default',
borderRadius: 10,
...displayFlex('flex-start', 'center'),
}}
dropAnimation={null}
>
<DragOverlay dropAnimation={null}>
{active
? renderChildren(active.data.current as DraggableTitleCellData)
: null}

View File

@@ -0,0 +1,66 @@
import { style } from '@vanilla-extract/css';
export const root = style({
height: '100%',
width: '100%',
display: 'flex',
flexFlow: 'column',
background: 'var(--affine-background-primary-color)',
});
export const scrollContainer = style({
flex: 1,
width: '100%',
paddingBottom: '32px',
});
export const allPagesHeader = style({
padding: '48px 16px 20px 24px',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
background: 'var(--affine-background-primary-color)',
});
export const allPagesHeaderTitle = style({
fontSize: 'var(--affine-font-h-3)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const titleIcon = style({
color: 'var(--affine-icon-color)',
display: 'inline-flex',
alignItems: 'center',
});
export const titleCollectionName = style({
color: 'var(--affine-text-primary-color)',
});
export const floatingToolbar = style({
position: 'absolute',
bottom: 26,
width: '100%',
zIndex: 1,
});
export const toolbarSelectedNumber = style({
color: 'var(--affine-text-secondary-color)',
});
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
});
export const newPageButtonLabel = style({
display: 'flex',
alignItems: 'center',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
});

View File

@@ -1,16 +1,50 @@
import { useCollectionManager } from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { toast } from '@affine/component';
import {
currentCollectionAtom,
FloatingToolbar,
NewPageButton as PureNewPageButton,
OperationCell,
PageList,
type PageListHandle,
PageListScrollContainer,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import {
CloseIcon,
DeleteIcon,
PlusIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
import { useCallback } from 'react';
import clsx from 'clsx';
import {
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect } from 'react-router-dom';
import { NIL } from 'uuid';
import { getUIAdapter } from '../../adapters/workspace';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting';
import * as styles from './all-page.css';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
@@ -27,39 +61,274 @@ export const loader: LoaderFunction = async args => {
return redirect(`/workspace/${workspace.id}/${page.id}`);
}
}
rootStore.set(currentCollectionAtom, NIL);
return null;
};
export const AllPage = () => {
const { jumpToPage } = useNavigateHelper();
const PageListHeader = () => {
const t = useAFFiNEI18N();
const setting = useCollectionManager(collectionsCRUDAtom);
const title = useMemo(() => {
if (setting.isDefault) {
return t['com.affine.all-pages.header']();
}
return (
<>
{t['com.affine.collections.header']()} /
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>
{setting.currentCollection.name}
</div>
</>
);
}, [setting.currentCollection.name, setting.isDefault, t]);
return (
<div className={styles.allPagesHeader}>
<div className={styles.allPagesHeaderTitle}>{title}</div>
<NewPageButton>{t['New Page']()}</NewPageButton>
</div>
);
};
const usePageOperationsRenderer = () => {
const [currentWorkspace] = useCurrentWorkspace();
const setting = useCollectionManager(currentCollectionsAtom);
const onClickPage = useCallback(
(pageId: string, newTab?: boolean) => {
assertExists(currentWorkspace);
if (newTab) {
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
} else {
jumpToPage(currentWorkspace.id, pageId);
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
);
const { toggleFavorite } = useBlockSuiteMetaHelper(
currentWorkspace.blockSuiteWorkspace
);
const t = useAFFiNEI18N();
const pageOperationsRenderer = useCallback(
(page: PageMeta) => {
const onDisablePublicSharing = () => {
toast('Successfully disabled', {
portal: document.body,
});
};
return (
<>
<OperationCell
favorite={!!page.favorite}
isPublic={!!page.isPublic}
onDisablePublicSharing={onDisablePublicSharing}
link={`/workspace/${currentWorkspace.id}/${page.id}`}
onRemoveToTrash={() =>
setTrashModal({
open: true,
pageIds: [page.id],
pageTitles: [page.title],
})
}
onToggleFavoritePage={() => {
const status = page.favorite;
toggleFavorite(page.id);
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}}
/>
</>
);
},
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
);
return pageOperationsRenderer;
};
const PageListFloatingToolbar = ({
selectedIds,
onClose,
}: {
selectedIds: string[];
onClose: () => void;
}) => {
const open = selectedIds.length > 0;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose();
}
},
[currentWorkspace, jumpToPage]
[onClose]
);
const { PageList, Header } = getUIAdapter(currentWorkspace.flavour);
const [currentWorkspace] = useCurrentWorkspace();
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const handleMultiDelete = useCallback(() => {
const pageNameMapping = Object.fromEntries(
pageMetas.map(meta => [meta.id, meta.title])
);
const pageNames = selectedIds.map(id => pageNameMapping[id] ?? '');
setTrashModal({
open: true,
pageIds: selectedIds,
pageTitles: pageNames,
});
}, [pageMetas, selectedIds, setTrashModal]);
return (
<>
<Header
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.ALL,
}}
<FloatingToolbar
className={styles.floatingToolbar}
open={open}
onOpenChange={handleOpenChange}
>
<FloatingToolbar.Item>
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedIds.length}
>
<div className={styles.toolbarSelectedNumber}>
{{ count: selectedIds.length } as any}
</div>
pages selected
</Trans>
</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator />
<FloatingToolbar.Button
onClick={handleMultiDelete}
icon={<DeleteIcon />}
type="danger"
/>
<PageList
collection={setting.currentCollection}
onOpenPage={onClickPage}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
</>
</FloatingToolbar>
);
};
const NewPageButton = ({
className,
children,
size,
}: PropsWithChildren<{
className?: string;
size?: 'small' | 'default';
}>) => {
const [currentWorkspace] = useCurrentWorkspace();
const { importFile, createEdgeless, createPage } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
return (
<div className={className}>
<PureNewPageButton
size={size}
importFile={importFile}
createNewEdgeless={createEdgeless}
createNewPage={createPage}
>
<div className={styles.newPageButtonLabel}>{children}</div>
</PureNewPageButton>
</div>
);
};
// even though it is called all page, it is also being used for collection route as well
export const AllPage = () => {
const [currentWorkspace] = useCurrentWorkspace();
const { isPreferredEdgeless } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const pageOperationsRenderer = usePageOperationsRenderer();
const filteredPageMetas = useFilteredPageMetas(
'all',
pageMetas,
currentWorkspace.blockSuiteWorkspace
);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const pageListRef = useRef<PageListHandle>(null);
const containerRef = useRef<HTMLDivElement>(null);
const deselectAllAndToggleSelect = useCallback(() => {
setSelectedPageIds([]);
pageListRef.current?.toggleSelectable();
}, []);
// make sure selected id is in the filtered list
const filteredSelectedPageIds = useMemo(() => {
const ids = filteredPageMetas.map(page => page.id);
return selectedPageIds.filter(id => ids.includes(id));
}, [filteredPageMetas, selectedPageIds]);
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false);
// when PageListScrollContainer scrolls above 40px, show the create new page button on header
useEffect(() => {
const container = containerRef.current;
if (container) {
const handleScroll = () => {
setTimeout(() => {
const scrollTop = container.scrollTop ?? 0;
setShowHeaderCreateNewPage(scrollTop > 40);
});
};
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}
return;
}, []);
return (
<div className={styles.root}>
{currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? (
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.ALL,
}}
rightSlot={
<NewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</NewPageButton>
}
/>
) : null}
<PageListScrollContainer
ref={containerRef}
className={styles.scrollContainer}
>
<PageListHeader />
{filteredPageMetas.length > 0 ? (
<>
<PageList
ref={pageListRef}
selectable="toggle"
draggable
selectedPageIds={filteredSelectedPageIds}
onSelectedPageIdsChange={setSelectedPageIds}
pages={filteredPageMetas}
clickMode="link"
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
<PageListFloatingToolbar
selectedIds={filteredSelectedPageIds}
onClose={deselectAllAndToggleSelect}
/>
</>
) : (
<EmptyPageList
type="all"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</PageListScrollContainer>
</div>
);
};

View File

@@ -0,0 +1,25 @@
import { style } from '@vanilla-extract/css';
export const placeholderButton = style({
padding: '8px 18px',
border: '1px solid var(--affine-border-color)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
fontWeight: 600,
cursor: 'pointer',
fontSize: 15,
lineHeight: '24px',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});
export const button = style({
userSelect: 'none',
borderRadius: 4,
cursor: 'pointer',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});

View File

@@ -0,0 +1,275 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import {
AffineShapeIcon,
currentCollectionAtom,
useCollectionManager,
useEditCollection,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import {
CloseIcon,
FilterIcon,
PageIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import { getCurrentStore } from '@toeverything/infra/atom';
import { useAtomValue } from 'jotai';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
import {
collectionsCRUDAtom,
pageCollectionBaseAtom,
} from '../../atoms/collections';
import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../../shared';
import { getWorkspaceSetting } from '../../utils/workspace-setting';
import { AllPage } from './all-page';
import * as styles from './collection.css';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
if (!args.params.collectionId) {
return redirect('/404');
}
rootStore.set(currentCollectionAtom, args.params.collectionId);
return null;
};
export const Component = function CollectionPage() {
const { collections, loading } = useAtomValue(pageCollectionBaseAtom);
const navigate = useNavigateHelper();
const params = useParams();
const [workspace] = useCurrentWorkspace();
const collection = collections.find(v => v.id === params.collectionId);
const pushNotification = useSetAtom(pushNotificationAtom);
useEffect(() => {
if (!loading && !collection) {
navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
const collection = getWorkspaceSetting(
workspace.blockSuiteWorkspace
).collectionsTrash.find(v => v.collection.id === params.collectionId);
let text = 'Collection is not exist';
if (collection) {
if (collection.userId) {
text = `${collection.collection.name} is deleted by ${collection.userName}`;
} else {
text = `${collection.collection.name} is deleted`;
}
}
pushNotification({
type: 'error',
title: text,
});
}
}, [
collection,
loading,
navigate,
params.collectionId,
pushNotification,
workspace.blockSuiteWorkspace,
workspace.id,
]);
if (loading) {
return null;
}
if (!collection) {
return null;
}
return isEmpty(collection) ? (
<Placeholder collection={collection} />
) : (
<AllPage />
);
};
const Placeholder = ({ collection }: { collection: Collection }) => {
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
const { node, open } = useEditCollection(useAllPageListConfig());
const openPageEdit = useCallback(() => {
open({ ...collection, mode: 'page' }).then(updateCollection);
}, [open, collection, updateCollection]);
const openRuleEdit = useCallback(() => {
open({ ...collection, mode: 'rule' }).then(updateCollection);
}, [collection, open, updateCollection]);
const [showTips, setShowTips] = useState(false);
useEffect(() => {
setShowTips(!localStorage.getItem('hide-empty-collection-help-info'));
}, []);
const hideTips = useCallback(() => {
setShowTips(false);
localStorage.setItem('hide-empty-collection-help-info', 'true');
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 24px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
color: 'var(--affine-text-secondary-color)',
}}
>
<ViewLayersIcon style={{ color: 'var(--affine-icon-color)' }} />
All Collections
<div>/</div>
</div>
<div
data-testid="collection-name"
style={{ fontWeight: 600, color: 'var(--affine-text-primary-color)' }}
>
{collection.name}
</div>
</div>
<div
style={{
display: 'flex',
flex: 1,
flexDirection: 'column',
alignItems: 'center',
gap: 64,
}}
>
<div
style={{
maxWidth: 432,
marginTop: 118,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 18,
margin: '118px 12px 0',
}}
>
<AffineShapeIcon />
<div
style={{
fontSize: 20,
lineHeight: '28px',
fontWeight: 600,
color: 'var(--affine-text-primary-color)',
}}
>
Empty Collection
</div>
<div
style={{
fontSize: 12,
lineHeight: '20px',
color: 'var(--affine-text-secondary-color)',
textAlign: 'center',
}}
>
Collection is a smart folder where you can manually add pages or
automatically add pages through rules.
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px 32px',
flexWrap: 'wrap',
justifyContent: 'center',
}}
>
<div onClick={openPageEdit} className={styles.placeholderButton}>
<PageIcon
style={{
width: 20,
height: 20,
color: 'var(--affine-icon-color)',
}}
/>
<span style={{ padding: '0 4px' }}>Add Pages</span>
</div>
<div onClick={openRuleEdit} className={styles.placeholderButton}>
<FilterIcon
style={{
width: 20,
height: 20,
color: 'var(--affine-icon-color)',
}}
/>
<span style={{ padding: '0 4px' }}>Add Rules</span>
</div>
</div>
</div>
{showTips ? (
<div
style={{
maxWidth: 452,
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
padding: 10,
gap: 14,
margin: '0 12px',
}}
>
<div
style={{
fontWeight: 600,
fontSize: 12,
lineHeight: '20px',
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>HELP INFO</div>
<CloseIcon
className={styles.button}
style={{ width: 16, height: 16 }}
onClick={hideTips}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
fontSize: 12,
lineHeight: '20px',
}}
>
<div>
<span style={{ fontWeight: 600 }}>Add pages:</span> You can
freely select pages and add them to the collection.
</div>
<div>
<span style={{ fontWeight: 600 }}>Add rules:</span> Rules are
based on filtering. After adding rules, pages that meet the
requirements will be automatically added to the current
collection.
</div>
</div>
</div>
) : null}
</div>
{node}
</div>
);
};
const isEmpty = (collection: Collection) => {
return (
(collection.mode === 'page' && collection.pages.length === 0) ||
(collection.mode === 'rule' &&
collection.allowList.length === 0 &&
collection.filterList.length === 0)
);
};

View File

@@ -23,11 +23,12 @@ import type { Map as YMap } from 'yjs';
import { getUIAdapter } from '../../adapters/workspace';
import { setPageModeAtom } from '../../atoms';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { currentModeAtom } from '../../atoms/mode';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting';
const DetailPageImpl = (): ReactElement => {
const { openPage, jumpToSubPath } = useNavigateHelper();
@@ -36,7 +37,7 @@ const DetailPageImpl = (): ReactElement => {
assertExists(currentWorkspace);
assertExists(currentPageId);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const collectionManager = useCollectionManager(currentCollectionsAtom);
const collectionManager = useCollectionManager(collectionsCRUDAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode);
@@ -66,7 +67,6 @@ const DetailPageImpl = (): ReactElement => {
});
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
collectionManager.backToAll();
collectionManager.setTemporaryFilter([createTagFilter(tagId)]);
});
return () => {
@@ -86,10 +86,10 @@ const DetailPageImpl = (): ReactElement => {
]
);
const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour);
const { PageDetail } = getUIAdapter(currentWorkspace.flavour);
return (
<>
<Header
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
pageId: currentPageId,

View File

@@ -0,0 +1,27 @@
import { style } from '@vanilla-extract/css';
export const pageListEmptyStyle = style({
height: 'calc(100% - 52px)',
});
export const emptyDescButton = style({
cursor: 'pointer',
color: 'var(--affine-text-secondary-color)',
background: 'var(--affine-background-code-block)',
border: '1px solid var(--affine-border-color)',
borderRadius: '4px',
padding: '0 6px',
boxSizing: 'border-box',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
},
});
export const emptyDescKbd = style([
emptyDescButton,
{
cursor: 'text',
},
]);

View File

@@ -0,0 +1,65 @@
import { Empty } from '@affine/component';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@blocksuite/store';
import { useCallback } from 'react';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import * as styles from './page-list-empty.css';
export const EmptyPageList = ({
type,
blockSuiteWorkspace,
}: {
type: 'all' | 'trash' | 'shared' | 'public';
blockSuiteWorkspace: Workspace;
}) => {
const { createPage } = usePageHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N();
const onCreatePage = useCallback(() => {
createPage?.();
}, [createPage]);
const getEmptyDescription = () => {
if (type === 'all') {
const createNewPageButton = (
<button className={styles.emptyDescButton} onClick={onCreatePage}>
New Page
</button>
);
if (environment.isDesktop) {
const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N';
return (
<Trans i18nKey="emptyAllPagesClient">
Click on the {createNewPageButton} button Or press
<kbd className={styles.emptyDescKbd}>{{ shortcut } as any}</kbd> to
create your first page.
</Trans>
);
}
return (
<Trans i18nKey="emptyAllPages">
Click on the
{createNewPageButton}
button to create your first page.
</Trans>
);
}
if (type === 'trash') {
return t['emptyTrash']();
}
if (type === 'shared') {
return t['emptySharedPages']();
}
return;
};
return (
<div className={styles.pageListEmptyStyle}>
<Empty
title={t['com.affine.emptyDesc']()}
description={getEmptyDescription()}
/>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { filterPage, useCollectionManager } from '@affine/component/page-list';
import type { PageMeta } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { allPageModeSelectAtom } from '../../atoms';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import type { BlockSuiteWorkspace } from '../../shared';
export const useFilteredPageMetas = (
route: 'all' | 'trash',
pageMetas: PageMeta[],
workspace: BlockSuiteWorkspace
) => {
const { isPreferredEdgeless } = usePageHelper(workspace);
const pageMode = useAtomValue(allPageModeSelectAtom);
const { currentCollection } = useCollectionManager(collectionsCRUDAtom);
const filteredPageMetas = useMemo(
() =>
pageMetas
.filter(pageMeta => {
if (pageMode === 'all') {
return true;
}
if (pageMode === 'edgeless') {
return isPreferredEdgeless(pageMeta.id);
}
if (pageMode === 'page') {
return !isPreferredEdgeless(pageMeta.id);
}
console.error('unknown filter mode', pageMeta, pageMode);
return true;
})
.filter(pageMeta => {
if (
(route === 'trash' && !pageMeta.trash) ||
(route === 'all' && pageMeta.trash)
) {
return false;
}
if (!currentCollection) {
return true;
}
return filterPage(currentCollection, pageMeta);
}),
[pageMetas, pageMode, isPreferredEdgeless, route, currentCollection]
);
return filteredPageMetas;
};

View File

@@ -1,43 +1,91 @@
import { toast } from '@affine/component';
import {
PageList,
PageListScrollContainer,
TrashOperationCell,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import type { PageMeta } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback } from 'react';
import { getUIAdapter } from '../../adapters/workspace';
import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import * as styles from './all-page.css';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
export const TrashPage = () => {
const { jumpToPage } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
const onClickPage = useCallback(
(pageId: string, newTab?: boolean) => {
assertExists(currentWorkspace);
if (newTab) {
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
} else {
jumpToPage(currentWorkspace.id, pageId);
}
},
[currentWorkspace, jumpToPage]
);
// todo(himself65): refactor to plugin
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
assertExists(blockSuiteWorkspace);
const { Header } = getUIAdapter(currentWorkspace.flavour);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const filteredPageMetas = useFilteredPageMetas(
'trash',
pageMetas,
currentWorkspace.blockSuiteWorkspace
);
const { restoreFromTrash, permanentlyDeletePage } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const { isPreferredEdgeless } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
const t = useAFFiNEI18N();
const pageOperationsRenderer = useCallback(
(page: PageMeta) => {
const onRestorePage = () => {
restoreFromTrash(page.id);
toast(
t['com.affine.toastMessage.restored']({
title: page.title || 'Untitled',
})
);
};
const onPermanentlyDeletePage = () => {
permanentlyDeletePage(page.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
};
return (
<TrashOperationCell
onPermanentlyDeletePage={onPermanentlyDeletePage}
onRestorePage={onRestorePage}
/>
);
},
[permanentlyDeletePage, restoreFromTrash, t]
);
return (
<>
<Header
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
<BlockSuitePageList
blockSuiteWorkspace={blockSuiteWorkspace}
onOpenPage={onClickPage}
listType="trash"
/>
<div className={styles.root}>
<PageListScrollContainer className={styles.scrollContainer}>
{filteredPageMetas.length > 0 ? (
<PageList
pages={filteredPageMetas}
clickMode="link"
groupBy={false}
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
) : (
<EmptyPageList
type="trash"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</PageListScrollContainer>
</div>
</>
);
};

View File

@@ -14,6 +14,10 @@ export const routes = [
path: 'all',
lazy: () => import('./pages/workspace/all-page'),
},
{
path: 'collection/:collectionId',
lazy: () => import('./pages/workspace/collection'),
},
{
path: 'trash',
lazy: () => import('./pages/workspace/trash-page'),

View File

@@ -1,18 +0,0 @@
import { filterByFilterList } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import type { PageMeta } from '@blocksuite/store';
export const filterPage = (collection: Collection, page: PageMeta) => {
if (collection.excludeList?.includes(page.id)) {
return false;
}
if (collection.allowList?.includes(page.id)) {
return true;
}
return filterByFilterList(collection.filterList, {
'Is Favourited': !!page.favorite,
Created: page.createDate,
Updated: page.updatedDate ?? page.createDate,
Tags: page.tags,
});
};

View File

@@ -1,208 +1,43 @@
import type { CollectionsAtom } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { DisposableGroup } from '@blocksuite/global/utils';
import { currentWorkspaceAtom } from '@toeverything/infra/atom';
import { type DBSchema, openDB } from 'idb';
import { atom } from 'jotai';
import { atomWithObservable } from 'jotai/utils';
import type { Workspace } from '@blocksuite/store';
import { nanoid } from 'nanoid';
import { Observable } from 'rxjs';
import type { Map as YMap } from 'yjs';
import { Doc as YDoc } from 'yjs';
export class UserSetting {
constructor(
private workspace: Workspace,
private userId: string
) {}
import { sessionAtom } from '../atoms/cloud-user';
export interface PageCollectionDBV1 extends DBSchema {
view: {
key: Collection['id'];
value: Collection;
};
}
export interface StorageCRUD<Value> {
get: (key: string) => Promise<Value | null>;
set: (key: string, value: Value) => Promise<string>;
delete: (key: string) => Promise<void>;
list: () => Promise<string[]>;
}
type Subscribe = () => void;
const collectionDBAtom = atom(
openDB<PageCollectionDBV1>('page-view', 1, {
upgrade(database) {
database.createObjectStore('view', {
keyPath: 'id',
});
},
})
);
const callbackSet = new Set<Subscribe>();
const localCollectionCRUDAtom = atom(get => ({
get: async (key: string) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view').objectStore('view');
return (await t.get(key)) ?? null;
},
set: async (key: string, value: Collection) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view', 'readwrite').objectStore('view');
await t.put(value);
callbackSet.forEach(cb => cb());
return key;
},
delete: async (key: string) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view', 'readwrite').objectStore('view');
callbackSet.forEach(cb => cb());
await t.delete(key);
},
list: async () => {
const db = await get(collectionDBAtom);
const t = db.transaction('view').objectStore('view');
return t.getAllKeys();
},
}));
const getCollections = async (
storage: StorageCRUD<Collection>
): Promise<Collection[]> => {
return storage
.list()
.then(async keys => {
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
v.filter((v): v is Collection => v !== null)
get setting(): YDoc {
const rootDoc = this.workspace.doc;
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
if (!settingMap.has(this.userId)) {
settingMap.set(
this.userId,
new YDoc({
guid: nanoid(),
})
);
})
.catch(error => {
console.error('Failed to load collections', error);
return [];
});
};
const pageCollectionBaseAtom = atomWithObservable<Collection[]>(get => {
const currentWorkspacePromise = get(currentWorkspaceAtom);
const session = get(sessionAtom);
const localCRUD = get(localCollectionCRUDAtom);
const userId = session?.data?.user.id ?? null;
const useLocalStorage = userId === null;
return new Observable<Collection[]>(subscriber => {
// initial value
subscriber.next([]);
if (useLocalStorage) {
const fn = () => {
getCollections(localCRUD).then(async collections => {
const workspaceId = (await currentWorkspacePromise).id;
subscriber.next(
collections.filter(c => c.workspaceId === workspaceId)
);
});
};
fn();
callbackSet.add(fn);
return () => {
callbackSet.delete(fn);
};
} else {
const group = new DisposableGroup();
currentWorkspacePromise.then(async currentWorkspace => {
const collectionsFromLocal = await getCollections(localCRUD);
const rootDoc = currentWorkspace.doc;
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
if (!settingMap.has(userId)) {
settingMap.set(
userId,
new YDoc({
guid: nanoid(),
})
);
}
const settingDoc = settingMap.get(userId) as YDoc;
if (!settingDoc.isLoaded) {
settingDoc.load();
await settingDoc.whenLoaded;
}
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
// sync local storage to doc
collectionsFromLocal.map(v => viewMap.set(v.id, v));
// delete from indexeddb
Promise.all(
collectionsFromLocal.map(async v => {
await localCRUD.delete(v.id);
})
).catch(error => {
console.error('Failed to delete collections from indexeddb', error);
});
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
.map(key => viewMap.get(key))
.filter((v): v is Collection => !!v);
const collections = [...collectionsFromDoc];
subscriber.next(collections);
if (group.disposed) {
return;
}
const fn = () => {
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
.map(key => viewMap.get(key))
.filter((v): v is Collection => !!v);
const collections = [...collectionsFromLocal, ...collectionsFromDoc];
subscriber.next(collections);
};
viewMap.observe(fn);
group.add(() => {
viewMap.unobserve(fn);
});
});
return () => {
group.dispose();
};
}
});
});
export const currentCollectionsAtom: CollectionsAtom = atom(
get => get(pageCollectionBaseAtom),
async (get, _, apply) => {
const collections = await get(pageCollectionBaseAtom);
let newCollections: Collection[];
if (typeof apply === 'function') {
newCollections = apply(collections);
} else {
newCollections = apply;
}
const session = get(sessionAtom);
const userId = session?.data?.user.id ?? null;
const useLocalStorage = userId === null;
const added = newCollections.filter(v => !collections.includes(v));
const removed = collections.filter(v => !newCollections.includes(v));
if (useLocalStorage) {
const localCRUD = get(localCollectionCRUDAtom);
await Promise.all([
...added.map(async v => {
await localCRUD.set(v.id, v);
}),
...removed.map(async v => {
await localCRUD.delete(v.id);
}),
]);
} else {
const currentWorkspace = await get(currentWorkspaceAtom);
const rootDoc = currentWorkspace.doc;
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
const settingDoc = settingMap.get(userId) as YDoc;
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
await Promise.all([
...added.map(async v => {
viewMap.set(v.id, v);
}),
...removed.map(async v => {
viewMap.delete(v.id);
}),
]);
}
return settingMap.get(this.userId) as YDoc;
}
);
get loaded(): Promise<void> {
if (!this.setting.isLoaded) {
this.setting.load();
}
return this.setting.whenLoaded;
}
/**
* @deprecated
*/
get view() {
return this.setting.getMap('view') as YMap<Collection>;
}
}
export const getUserSetting = (workspace: Workspace, userId: string) => {
return new UserSetting(workspace, userId);
};

View File

@@ -0,0 +1,125 @@
import type {
Collection,
DeleteCollectionInfo,
DeletedCollection,
} from '@affine/env/filter';
import type { Workspace } from '@blocksuite/store';
import { Array as YArray } from 'yjs';
import { updateFirstOfYArray } from './yjs-utils';
const COLLECTIONS_KEY = 'collections';
const COLLECTIONS_TRASH_KEY = 'collections_trash';
const SETTING_KEY = 'setting';
export class WorkspaceSetting {
constructor(private workspace: Workspace) {}
get doc() {
return this.workspace.doc;
}
get setting() {
return this.workspace.doc.getMap(SETTING_KEY);
}
get collectionsYArray() {
if (!this.setting.has(COLLECTIONS_KEY)) {
this.setting.set(COLLECTIONS_KEY, new YArray());
}
return this.setting.get(COLLECTIONS_KEY) as YArray<Collection>;
}
get collectionsTrashYArray() {
if (!this.setting.has(COLLECTIONS_TRASH_KEY)) {
this.setting.set(COLLECTIONS_TRASH_KEY, new YArray());
}
return this.setting.get(COLLECTIONS_TRASH_KEY) as YArray<DeletedCollection>;
}
get collections(): Collection[] {
return this.collectionsYArray.toArray() ?? [];
}
get collectionsTrash(): DeletedCollection[] {
return this.collectionsTrashYArray.toArray() ?? [];
}
updateCollection(id: string, updater: (value: Collection) => Collection) {
updateFirstOfYArray(
this.collectionsYArray,
v => v.id === id,
v => {
return updater(v);
}
);
}
addCollection(...collections: Collection[]) {
this.doc.transact(() => {
this.collectionsYArray.insert(0, collections);
});
}
deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) {
const set = new Set(ids);
this.workspace.doc.transact(() => {
const indexList: number[] = [];
const list: Collection[] = [];
this.collectionsYArray.forEach((collection, i) => {
if (set.has(collection.id)) {
set.delete(collection.id);
indexList.unshift(i);
list.push(JSON.parse(JSON.stringify(collection)));
}
});
indexList.forEach(i => {
this.collectionsYArray.delete(i);
});
this.collectionsTrashYArray.insert(
0,
list.map(collection => ({
userId: info?.userId,
userName: info ? info.userName : 'Local User',
collection,
}))
);
if (this.collectionsTrashYArray.length > 10) {
this.collectionsTrashYArray.delete(
10,
this.collectionsTrashYArray.length - 10
);
}
});
}
deletePagesFromCollection(collection: Collection, idSet: Set<string>) {
const newAllowList = collection.allowList.filter(id => !idSet.has(id));
const newPages = collection.pages.filter(id => !idSet.has(id));
if (
newAllowList.length !== collection.allowList.length ||
newPages.length !== collection.pages.length
) {
this.updateCollection(collection.id, old => {
return {
...old,
allowList: newAllowList,
pages: newPages,
};
});
}
}
deletePages(ids: string[]) {
const idSet = new Set(ids);
this.workspace.doc.transact(() => {
this.collections.forEach(collection => {
this.deletePagesFromCollection(collection, idSet);
});
});
}
}
export const getWorkspaceSetting = (workspace: Workspace) => {
return new WorkspaceSetting(workspace);
};

View File

@@ -0,0 +1,18 @@
import type { Array as YArray } from 'yjs';
export const updateFirstOfYArray = <T>(
array: YArray<T>,
p: (value: T) => boolean,
update: (value: T) => T
) => {
array.doc?.transact(() => {
for (let i = 0; i < array.length; i++) {
const ele = array.get(i);
if (p(ele)) {
array.delete(i);
array.insert(i, [update(ele)]);
return;
}
}
});
};