From c60648ce9b5d7955420df50ac4047884bdf9a421 Mon Sep 17 00:00:00 2001 From: 3720 Date: Fri, 30 Jun 2023 13:40:00 +0800 Subject: [PATCH] feat: support for view management (#2892) --- apps/storybook/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/adapters/affine/index.tsx | 4 +- apps/web/src/adapters/local/index.tsx | 4 +- .../block-suite-page-list/index.tsx | 27 +- .../collections/collections-list.tsx | 274 +++++++++++++++++ .../collections/index.tsx | 3 + .../workspace-slider-bar/collections/page.tsx | 200 ++++++++++++ .../collections/styles.css.ts | 51 +++ .../components/reference-page.tsx | 81 +++++ .../favorite/favorite-list.tsx | 86 +----- .../pure/workspace-slider-bar/index.tsx | 4 + .../src/components/root-app-sidebar/index.tsx | 6 +- apps/web/src/components/workspace-header.tsx | 84 ++--- apps/web/src/hooks/use-get-page-info.ts | 27 ++ apps/web/src/layouts/workspace-layout.tsx | 3 + .../src/pages/workspace/[workspaceId]/all.tsx | 2 +- apps/web/src/utils/filter.ts | 17 + packages/component/package.json | 2 +- .../app-sidebar/menu-item/index.css.ts | 13 +- .../app-sidebar/menu-item/index.tsx | 9 +- .../__tests__/use-all-page-setting.spec.ts | 27 +- .../src/components/page-list/all-page.tsx | 8 +- .../page-list/filter/filter-list.tsx | 9 +- .../components/page-list/filter/index.css.ts | 1 - .../src/components/page-list/type.ts | 3 + .../page-list/use-all-page-setting.ts | 133 ++++++-- .../page-list/view/collection-bar.css.ts | 48 +++ .../page-list/view/collection-bar.tsx | 126 ++++++++ .../page-list/view/collection-list.css.ts | 207 +++++++++++++ .../page-list/view/collection-list.tsx | 232 ++++++++++++++ .../page-list/view/create-collection.tsx | 291 ++++++++++++++++++ .../components/page-list/view/create-view.tsx | 112 ------- .../src/components/page-list/view/index.ts | 5 +- .../page-list/view/view-list.css.ts | 76 ----- .../components/page-list/view/view-list.tsx | 72 ----- .../component/src/ui/scrollbar/scrollbar.tsx | 10 +- packages/env/src/filter.ts | 5 +- packages/env/src/page-info.ts | 7 + packages/env/src/workspace.ts | 4 +- packages/i18n/src/resources/en.json | 1 + tests/libs/page-logic.ts | 6 + tests/parallels/all-page.spec.ts | 12 +- .../local-first-collections-items.spec.ts | 103 +++++++ yarn.lock | 14 +- 45 files changed, 1936 insertions(+), 477 deletions(-) create mode 100644 apps/web/src/components/pure/workspace-slider-bar/collections/collections-list.tsx create mode 100644 apps/web/src/components/pure/workspace-slider-bar/collections/index.tsx create mode 100644 apps/web/src/components/pure/workspace-slider-bar/collections/page.tsx create mode 100644 apps/web/src/components/pure/workspace-slider-bar/collections/styles.css.ts create mode 100644 apps/web/src/components/pure/workspace-slider-bar/components/reference-page.tsx create mode 100644 apps/web/src/hooks/use-get-page-info.ts create mode 100644 apps/web/src/utils/filter.ts create mode 100644 packages/component/src/components/page-list/view/collection-bar.css.ts create mode 100644 packages/component/src/components/page-list/view/collection-bar.tsx create mode 100644 packages/component/src/components/page-list/view/collection-list.css.ts create mode 100644 packages/component/src/components/page-list/view/collection-list.tsx create mode 100644 packages/component/src/components/page-list/view/create-collection.tsx delete mode 100644 packages/component/src/components/page-list/view/create-view.tsx delete mode 100644 packages/component/src/components/page-list/view/view-list.css.ts delete mode 100644 packages/component/src/components/page-list/view/view-list.tsx create mode 100644 packages/env/src/page-info.ts create mode 100644 tests/parallels/local-first-collections-items.spec.ts diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 863b384fea..0a4d8ef5c4 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -34,7 +34,7 @@ "@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly", - "@blocksuite/icons": "^2.1.21", + "@blocksuite/icons": "^2.1.23", "@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly", "react": "18.3.0-canary-8ec962d82-20230623", diff --git a/apps/web/package.json b/apps/web/package.json index 64d39190cc..544df0bb13 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly", - "@blocksuite/icons": "^2.1.21", + "@blocksuite/icons": "^2.1.23", "@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly", "@dnd-kit/core": "^6.0.8", diff --git a/apps/web/src/adapters/affine/index.tsx b/apps/web/src/adapters/affine/index.tsx index 4a20f3f2b4..48eb460295 100644 --- a/apps/web/src/adapters/affine/index.tsx +++ b/apps/web/src/adapters/affine/index.tsx @@ -336,10 +336,10 @@ export const AffineAdapter: WorkspaceAdapter = { ); }, - PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => { + PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => { return ( = { ); }, - PageList: ({ blockSuiteWorkspace, onOpenPage, view }) => { + PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => { return ( diff --git a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx index 214ab35674..9f9684cf1d 100644 --- a/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx +++ b/apps/web/src/components/blocksuite/block-suite-page-list/index.tsx @@ -1,11 +1,7 @@ import { Empty } from '@affine/component'; import type { ListData, TrashListData } from '@affine/component/page-list'; -import { - filterByFilterList, - PageList, - PageListTrashView, -} from '@affine/component/page-list'; -import type { View } from '@affine/env/filter'; +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 { EdgelessIcon, PageIcon } from '@blocksuite/icons'; @@ -18,8 +14,10 @@ import { useMemo } from 'react'; import { allPageModeSelectAtom } from '../../../atoms'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; +import { useGetPageInfoById } from '../../../hooks/use-get-page-info'; import type { BlockSuiteWorkspace } from '../../../shared'; import { toast } from '../../../utils'; +import { filterPage } from '../../../utils/filter'; import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css'; import { usePageHelper } from './utils'; @@ -28,7 +26,7 @@ export type BlockSuitePageListProps = { listType: 'all' | 'trash' | 'shared' | 'public'; isPublic?: true; onOpenPage: (pageId: string, newTab?: boolean) => void; - view?: View; + collection?: Collection; }; const filter = { @@ -97,7 +95,7 @@ export const BlockSuitePageList: React.FC = ({ onOpenPage, listType, isPublic = false, - view, + collection, }) => { const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace); const { @@ -111,6 +109,7 @@ export const BlockSuitePageList: React.FC = ({ const { createPage, createEdgeless, importFile, isPreferredEdgeless } = usePageHelper(blockSuiteWorkspace); const t = useAFFiNEI18N(); + const getPageInfo = useGetPageInfoById(); const list = useMemo( () => pageMetas @@ -131,16 +130,12 @@ export const BlockSuitePageList: React.FC = ({ if (!filter[listType](pageMeta, pageMetas)) { return false; } - if (!view) { + if (!collection) { return true; } - return filterByFilterList(view.filterList, { - 'Is Favourited': !!pageMeta.favorite, - Created: pageMeta.createDate, - Updated: pageMeta.updatedDate ?? pageMeta.createDate, - }); + return filterPage(collection, pageMeta); }), - [pageMetas, filterMode, isPreferredEdgeless, listType, view] + [pageMetas, filterMode, isPreferredEdgeless, listType, collection] ); if (listType === 'trash') { @@ -222,9 +217,9 @@ export const BlockSuitePageList: React.FC = ({ }, }; }); - return ( { + return typeof id === 'string' && id.startsWith(Collections_DROP_AREA_PREFIX); +}; +export const processCollectionsDrag = (e: DragEndEvent) => { + if ( + isCollectionsDropArea(e.over?.id) && + String(e.active.id).startsWith('page-list-item-') + ) { + e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId); + } +}; +const CollectionOperations = ({ + view, + showUpdateCollection, + setting, +}: { + view: Collection; + showUpdateCollection: () => void; + setting: ReturnType; +}) => { + const actions = useMemo< + Array< + | { + icon: ReactElement; + name: string; + click: () => void; + className?: string; + element?: undefined; + } + | { + element: ReactElement; + } + > + >( + () => [ + { + icon: , + name: 'Edit Filter', + click: showUpdateCollection, + }, + { + icon: , + name: 'Unpin', + click: () => { + return setting.updateCollection({ + ...view, + pinned: false, + }); + }, + }, + { + element:
, + }, + { + icon: , + name: 'Delete', + click: () => { + return setting.deleteCollection(view.id); + }, + className: styles.deleteFolder, + }, + ], + [setting, showUpdateCollection, view] + ); + return ( +
+ {actions.map(action => { + if (action.element) { + return action.element; + } + return ( + + {action.name} + + ); + })} +
+ ); +}; +const CollectionRenderer = ({ + collection, + pages, + workspace, + getPageInfo, +}: { + collection: Collection; + pages: PageMeta[]; + workspace: AllWorkspace; + getPageInfo: GetPageInfoById; +}) => { + const [collapsed, setCollapsed] = React.useState(true); + const setting = useAllPageSetting(); + const router = useRouter(); + const clickCollection = useCallback(() => { + router + .push(`/workspace/${workspace.id}/all`) + .then(() => { + setting.selectCollection(collection.id); + }) + .catch(err => { + console.error(err); + }); + }, [router, workspace.id, setting, collection.id]); + const { setNodeRef, isOver } = useDroppable({ + id: `${Collections_DROP_AREA_PREFIX}${collection.id}`, + data: { + addToCollection: (id: string) => { + setting.addPage(collection.id, id).catch(err => { + console.error(err); + }); + }, + }, + }); + 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({ + ...collection, + allowList: collection.allowList?.filter(v => v != id), + }); + }, + [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 + ); + return ( + + showUpdateCollection(false)} + /> + } + postfix={ + showUpdateCollection(true)} + setting={setting} + /> + } + > +
+ +
+
+ } + collapsed={pagesToRender.length > 0 ? collapsed : undefined} + onClick={clickCollection} + > +
+
{collection.name}
+
+
+ +
+ {pagesToRender.map(page => { + return ( + + ); + })} +
+
+
+ ); +}; +export const CollectionsList = ({ currentWorkspace }: CollectionsListProps) => { + const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const { savedCollections } = useSavedCollections(); + const getPageInfo = useGetPageInfoById(); + return ( +
+ {savedCollections + .filter(v => v.pinned) + .map(view => { + return ( + + ); + })} +
+ ); +}; diff --git a/apps/web/src/components/pure/workspace-slider-bar/collections/index.tsx b/apps/web/src/components/pure/workspace-slider-bar/collections/index.tsx new file mode 100644 index 0000000000..1a49849f4d --- /dev/null +++ b/apps/web/src/components/pure/workspace-slider-bar/collections/index.tsx @@ -0,0 +1,3 @@ +export * from './collections-list'; +export { Page } from './page'; +export { PageOperations } from './page'; diff --git a/apps/web/src/components/pure/workspace-slider-bar/collections/page.tsx b/apps/web/src/components/pure/workspace-slider-bar/collections/page.tsx new file mode 100644 index 0000000000..fe8c47c543 --- /dev/null +++ b/apps/web/src/components/pure/workspace-slider-bar/collections/page.tsx @@ -0,0 +1,200 @@ +import { Menu } from '@affine/component'; +import { MenuItem } from '@affine/component/app-sidebar'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + DeleteIcon, + EdgelessIcon, + FilterIcon, + MoreHorizontalIcon, + PageIcon, +} from '@blocksuite/icons'; +import type { PageMeta, Workspace } from '@blocksuite/store'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references'; +import { useAtomValue } from 'jotai/index'; +import { useRouter } from 'next/router'; +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; + +import { pageSettingFamily } from '../../../../atoms'; +import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper'; +import type { AllWorkspace } from '../../../../shared'; +import { ReferencePage } from '../components/reference-page'; +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 { removeToTrash } = useBlockSuiteMetaHelper(workspace); + const actions = useMemo< + Array< + | { + icon: ReactElement; + name: string; + click: () => void; + className?: string; + element?: undefined; + } + | { + element: ReactElement; + } + > + >( + () => [ + ...(inAllowList + ? [ + { + icon: , + name: 'Remove special filter', + click: () => removeFromAllowList(page.id), + }, + ] + : []), + ...(!inExcludeList + ? [ + { + icon: , + name: 'Exclude from filter', + click: () => addToExcludeList(page.id), + }, + ] + : []), + { + element:
, + }, + { + icon: , + name: 'Delete', + click: () => { + removeToTrash(page.id); + }, + className: styles.deleteFolder, + }, + ], + [ + inAllowList, + inExcludeList, + page.id, + removeFromAllowList, + addToExcludeList, + removeToTrash, + ] + ); + return ( + <> + {actions.map(action => { + if (action.element) { + return action.element; + } + return ( + + {action.name} + + ); + })} + + ); +}; +export const Page = ({ + page, + workspace, + allPageMeta, + inAllowList, + inExcludeList, + removeFromAllowList, + addToExcludeList, +}: { + page: PageMeta; + inAllowList: boolean; + removeFromAllowList: (id: string) => void; + inExcludeList: boolean; + addToExcludeList: (id: string) => void; + workspace: AllWorkspace; + allPageMeta: Record; +}) => { + const [collapsed, setCollapsed] = React.useState(true); + const router = useRouter(); + const t = useAFFiNEI18N(); + const pageId = page.id; + const active = router.query.pageId === pageId; + const setting = useAtomValue(pageSettingFamily(pageId)); + const icon = setting?.mode === 'edgeless' ? : ; + const references = useBlockSuitePageReferences( + workspace.blockSuiteWorkspace, + pageId + ); + const clickPage = useCallback(() => { + return router.push(`/workspace/${workspace.id}/${page.id}`); + }, [page.id, router, workspace.id]); + const referencesToRender = references.filter(id => !allPageMeta[id]?.trash); + return ( + + 0 ? collapsed : undefined} + onCollapsedChange={setCollapsed} + postfix={ + + + + } + > +
+ +
+
+ } + > + {page.title || t['Untitled']()} +
+ +
+ {referencesToRender.map(id => { + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/apps/web/src/components/pure/workspace-slider-bar/collections/styles.css.ts b/apps/web/src/components/pure/workspace-slider-bar/collections/styles.css.ts new file mode 100644 index 0000000000..137d7e2430 --- /dev/null +++ b/apps/web/src/components/pure/workspace-slider-bar/collections/styles.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; + +export const wrapper = style({ + userSelect: 'none', + // marginLeft:8, +}); +export const collapsedIcon = style({ + transition: 'transform 0.2s ease-in-out', + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + }, +}); +export const view = style({ + display: 'flex', + alignItems: 'center', +}); +export const viewTitle = style({ + display: 'flex', + alignItems: 'center', +}); +export const title = style({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); +export const more = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 4, + padding: 4, + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, +}); +export const deleteFolder = style({ + color: 'var(--affine-warning-color)', + ':hover': { + backgroundColor: 'var(--affine-background-warning-color)', + }, +}); +export const menuDividerStyle = style({ + marginTop: '2px', + marginBottom: '2px', + marginLeft: '12px', + marginRight: '8px', + height: '1px', + background: 'var(--affine-border-color)', +}); diff --git a/apps/web/src/components/pure/workspace-slider-bar/components/reference-page.tsx b/apps/web/src/components/pure/workspace-slider-bar/components/reference-page.tsx new file mode 100644 index 0000000000..9b249e0195 --- /dev/null +++ b/apps/web/src/components/pure/workspace-slider-bar/components/reference-page.tsx @@ -0,0 +1,81 @@ +import { MenuLinkItem } from '@affine/component/app-sidebar'; +import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; +import type { PageMeta, Workspace } from '@blocksuite/store'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references'; +import { useAtomValue } from 'jotai/index'; +import { useRouter } from 'next/router'; +import { useMemo, useState } from 'react'; + +import { pageSettingFamily } from '../../../../atoms'; +import * as styles from '../favorite/styles.css'; +interface ReferencePageProps { + workspace: Workspace; + pageId: string; + metaMapping: Record; + parentIds: Set; +} + +export const ReferencePage = ({ + workspace, + pageId, + metaMapping, + parentIds, +}: ReferencePageProps) => { + const router = useRouter(); + const setting = useAtomValue(pageSettingFamily(pageId)); + const active = router.query.pageId === pageId; + const icon = setting?.mode === 'edgeless' ? : ; + const references = useBlockSuitePageReferences(workspace, pageId); + const referencesToShow = useMemo(() => { + return [ + ...new Set( + references.filter( + ref => !parentIds.has(ref) && !metaMapping[ref]?.trash + ) + ), + ]; + }, [references, parentIds, metaMapping]); + const [collapsed, setCollapsed] = useState(true); + const collapsible = referencesToShow.length > 0; + const nestedItem = parentIds.size > 0; + const untitled = !metaMapping[pageId]?.title; + return ( + + + + {metaMapping[pageId]?.title || 'Untitled'} + + + {collapsible && ( + +
+ {referencesToShow.map(ref => { + return ( + + ); + })} +
+
+ )} +
+ ); +}; diff --git a/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx b/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx index dd2ec28f2f..deff7880d7 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx +++ b/apps/web/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx @@ -1,88 +1,10 @@ -import { MenuLinkItem } from '@affine/component/app-sidebar'; -import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; -import type { PageMeta, Workspace } from '@blocksuite/store'; -import * as Collapsible from '@radix-ui/react-collapsible'; +import type { PageMeta } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; -import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references'; -import { useAtomValue } from 'jotai'; -import { useRouter } from 'next/router'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; -import { pageSettingFamily } from '../../../../atoms'; +import { ReferencePage } from '../components/reference-page'; import type { FavoriteListProps } from '../index'; import EmptyItem from './empty-item'; -import * as styles from './styles.css'; - -interface FavoriteMenuItemProps { - workspace: Workspace; - pageId: string; - metaMapping: Record; - parentIds: Set; -} - -function FavoriteMenuItem({ - workspace, - pageId, - metaMapping, - parentIds, -}: FavoriteMenuItemProps) { - const router = useRouter(); - const setting = useAtomValue(pageSettingFamily(pageId)); - const active = router.query.pageId === pageId; - const icon = setting?.mode === 'edgeless' ? : ; - const references = useBlockSuitePageReferences(workspace, pageId); - const referencesToShow = useMemo(() => { - return [ - ...new Set( - references.filter( - ref => !parentIds.has(ref) && !metaMapping[ref]?.trash - ) - ), - ]; - }, [references, parentIds, metaMapping]); - const [collapsed, setCollapsed] = useState(true); - const collapsible = referencesToShow.length > 0; - const nestedItem = parentIds.size > 0; - const untitled = !metaMapping[pageId]?.title; - return ( - - - - {metaMapping[pageId]?.title || 'Untitled'} - - - {collapsible && ( - -
- {referencesToShow.map(ref => { - return ( - - ); - })} -
-
- )} -
- ); -} export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => { const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); @@ -105,7 +27,7 @@ export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => { <> {favoriteList.map((pageMeta, index) => { return ( - {t['Shared Pages']()} ))} - + + {blockSuiteWorkspace && ( + + )} ): ReactElement { const setting = useAllPageSetting(); const t = useAFFiNEI18N(); + const saveToCollection = useCallback( + async (collection: Collection) => { + await setting.saveCollection(collection); + setting.selectCollection(collection.id); + }, + [setting] + ); + const getPageInfoById = useGetPageInfoById(); if ('subPath' in currentEntry) { if (currentEntry.subPath === WorkspaceSubPath.ALL) { - const leftSlot = ; - const filterContainer = setting.currentView.filterList.length > 0 && ( -
-
- { - setting.setCurrentView(view => ({ - ...view, - filterList, - })); - }} - /> -
- {runtimeConfig.enableAllPageSaving && ( -
- {setting.currentView.id !== NIL || - (setting.currentView.id === NIL && - setting.currentView.filterList.length > 0) ? ( - - ) : ( - - )} -
- )} -
+ const leftSlot = ( + ); + const filterContainer = + setting.isDefault && setting.currentCollection.filterList.length > 0 ? ( +
+
+ { + return setting.updateCollection({ + ...setting.currentCollection, + filterList, + }); + }} + /> +
+
+ {setting.currentCollection.filterList.length > 0 ? ( + + ) : null} +
+
+ ) : null; return ( <> { + const currentWorkspace = useAtomValue(rootCurrentWorkspaceAtom); + const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const pageMap = useMemo( + () => Object.fromEntries(pageMetas.map(page => [page.id, page])), + [pageMetas] + ); + const pageSettings = useAtomValue(pageSettingsAtom); + return (id: string) => { + const page = pageMap[id]; + if (!page) { + return; + } + return { + ...page, + isEdgeless: pageSettings[id]?.mode === 'edgeless', + }; + }; +}; diff --git a/apps/web/src/layouts/workspace-layout.tsx b/apps/web/src/layouts/workspace-layout.tsx index 5d7137e453..1ea9a4fd15 100644 --- a/apps/web/src/layouts/workspace-layout.tsx +++ b/apps/web/src/layouts/workspace-layout.tsx @@ -50,6 +50,7 @@ import { import { AppContainer } from '../components/affine/app-container'; import type { IslandItemNames } from '../components/pure/help-island'; import { HelpIsland } from '../components/pure/help-island'; +import { processCollectionsDrag } from '../components/pure/workspace-slider-bar/collections'; import { DROPPABLE_SIDEBAR_TRASH, RootAppSidebar, @@ -393,6 +394,8 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { moveToTrash(pageId); toast(t['Successfully deleted']()); } + // Drag page into Collections + processCollectionsDrag(e); }, [moveToTrash, t] ); diff --git a/apps/web/src/pages/workspace/[workspaceId]/all.tsx b/apps/web/src/pages/workspace/[workspaceId]/all.tsx index aa5c6fb337..9cf7e2204f 100644 --- a/apps/web/src/pages/workspace/[workspaceId]/all.tsx +++ b/apps/web/src/pages/workspace/[workspaceId]/all.tsx @@ -51,7 +51,7 @@ const AllPage: NextPageWithLayout = () => { }} /> diff --git a/apps/web/src/utils/filter.ts b/apps/web/src/utils/filter.ts new file mode 100644 index 0000000000..c663de2a3e --- /dev/null +++ b/apps/web/src/utils/filter.ts @@ -0,0 +1,17 @@ +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, + }); +}; diff --git a/packages/component/package.json b/packages/component/package.json index 7566c618c1..ad6c91f1d3 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -57,7 +57,7 @@ "@blocksuite/blocks": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/editor": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/global": "0.0.0-20230629103121-76e6587d-nightly", - "@blocksuite/icons": "^2.1.21", + "@blocksuite/icons": "^2.1.23", "@blocksuite/lit": "0.0.0-20230629103121-76e6587d-nightly", "@blocksuite/store": "0.0.0-20230629103121-76e6587d-nightly", "@types/react": "^18.2.14", diff --git a/packages/component/src/components/app-sidebar/menu-item/index.css.ts b/packages/component/src/components/app-sidebar/menu-item/index.css.ts index 611d361382..3b213659b3 100644 --- a/packages/component/src/components/app-sidebar/menu-item/index.css.ts +++ b/packages/component/src/components/app-sidebar/menu-item/index.css.ts @@ -10,6 +10,7 @@ export const root = style({ cursor: 'pointer', padding: '0 8px 0 12px', fontSize: 'var(--affine-font-sm)', + margin: '2px 0', selectors: { '&:hover': { background: 'var(--affine-hover-color)', @@ -22,11 +23,12 @@ export const root = style({ color: 'var(--affine-text-secondary-color)', pointerEvents: 'none', }, - '&[data-active="true"]:hover': { - background: - // make this a variable? - 'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04);', - }, + // this is not visible in dark mode + // '&[data-active="true"]:hover': { + // background: + // // make this a variable? + // 'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04)', + // }, '&[data-collapsible="true"]': { width: 'calc(100% + 8px)', transform: 'translateX(-8px)', @@ -39,6 +41,7 @@ export const content = style({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + flex: 1, }); export const icon = style({ diff --git a/packages/component/src/components/app-sidebar/menu-item/index.tsx b/packages/component/src/components/app-sidebar/menu-item/index.tsx index 98da84279a..7ffe5d8bf8 100644 --- a/packages/component/src/components/app-sidebar/menu-item/index.tsx +++ b/packages/component/src/components/app-sidebar/menu-item/index.tsx @@ -12,6 +12,7 @@ export interface MenuItemProps extends React.HTMLAttributes { disabled?: boolean; collapsed?: boolean; // true, false, undefined. undefined means no collapse onCollapsedChange?: (collapsed: boolean) => void; + postfix?: React.ReactElement; } export interface MenuLinkItemProps @@ -28,6 +29,7 @@ export const MenuItem = React.forwardRef( disabled, collapsed, onCollapsedChange, + postfix, ...props }, ref @@ -43,7 +45,6 @@ export const MenuItem = React.forwardRef( ref={ref} {...props} className={clsx([styles.root, props.className])} - onClick={onClick} data-active={active} data-disabled={disabled} data-collapsible={collapsible} @@ -68,11 +69,15 @@ export const MenuItem = React.forwardRef( )} {React.cloneElement(icon, { className: clsx([styles.icon, icon.props.className]), + onClick: onClick, })} )} -
{children}
+
+ {children} +
+ {postfix} ); } diff --git a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index ee027a49a9..61dfe12b81 100644 --- a/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -4,7 +4,6 @@ import 'fake-indexeddb/auto'; import { renderHook } from '@testing-library/react'; -import { RESET } from 'jotai/utils'; import { expect, test } from 'vitest'; import { createDefaultFilter, vars } from '../filter/vars'; @@ -12,22 +11,22 @@ import { useAllPageSetting } from '../use-all-page-setting'; test('useAllPageSetting', async () => { const settingHook = renderHook(() => useAllPageSetting()); - const prevView = settingHook.result.current.currentView; - expect(settingHook.result.current.savedViews).toEqual([]); - settingHook.result.current.setCurrentView(view => ({ - ...view, + const prevCollection = settingHook.result.current.currentCollection; + expect(settingHook.result.current.savedCollections).toEqual([]); + await settingHook.result.current.updateCollection({ + ...settingHook.result.current.currentCollection, filterList: [createDefaultFilter(vars[0])], - })); + }); settingHook.rerender(); - const nextView = settingHook.result.current.currentView; - expect(nextView).not.toBe(prevView); - expect(nextView.filterList).toEqual([createDefaultFilter(vars[0])]); - settingHook.result.current.setCurrentView(RESET); - await settingHook.result.current.createView({ - ...settingHook.result.current.currentView, + const nextCollection = settingHook.result.current.currentCollection; + expect(nextCollection).not.toBe(prevCollection); + expect(nextCollection.filterList).toEqual([createDefaultFilter(vars[0])]); + settingHook.result.current.backToAll(); + await settingHook.result.current.saveCollection({ + ...settingHook.result.current.currentCollection, id: '1', }); settingHook.rerender(); - expect(settingHook.result.current.savedViews.length).toBe(1); - expect(settingHook.result.current.savedViews[0].id).toBe('1'); + expect(settingHook.result.current.savedCollections.length).toBe(1); + expect(settingHook.result.current.savedCollections[0].id).toBe('1'); }); diff --git a/packages/component/src/components/page-list/all-page.tsx b/packages/component/src/components/page-list/all-page.tsx index 3cf40ccec1..aec0d7be0c 100644 --- a/packages/component/src/components/page-list/all-page.tsx +++ b/packages/component/src/components/page-list/all-page.tsx @@ -1,4 +1,6 @@ +import { CollectionBar } from '@affine/component/page-list'; import { DEFAULT_SORT_KEY } from '@affine/env/constant'; +import type { GetPageInfoById } from '@affine/env/page-info'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons'; import { useMediaQuery, useTheme } from '@mui/material'; @@ -31,12 +33,14 @@ const AllPagesHead = ({ createNewPage, createNewEdgeless, importFile, + getPageInfo, }: { isPublicWorkspace: boolean; sorter: ReturnType>; createNewPage: () => void; createNewEdgeless: () => void; importFile: () => void; + getPageInfo: GetPageInfoById; }) => { const t = useAFFiNEI18N(); const titleList = [ @@ -72,7 +76,6 @@ const AllPagesHead = ({ } satisfies CSSProperties, }, ]; - return ( @@ -107,6 +110,7 @@ const AllPagesHead = ({ ))} + ); }; @@ -118,6 +122,7 @@ export const PageList = ({ onCreateNewEdgeless, onImportFile, fallback, + getPageInfo, }: PageListProps) => { const sorter = useSorter({ data: list, @@ -160,6 +165,7 @@ export const PageList = ({ createNewPage={onCreateNewPage} createNewEdgeless={onCreateNewEdgeless} importFile={onImportFile} + getPageInfo={getPageInfo} /> void; }) => { return ( -
+
{value.map((filter, i) => { return (
diff --git a/packages/component/src/components/page-list/filter/index.css.ts b/packages/component/src/components/page-list/filter/index.css.ts index 44cd265f04..5c42e68238 100644 --- a/packages/component/src/components/page-list/filter/index.css.ts +++ b/packages/component/src/components/page-list/filter/index.css.ts @@ -28,7 +28,6 @@ export const filterItemStyle = style({ border: '1px solid var(--affine-border-color)', borderRadius: '8px', background: 'var(--affine-white)', - margin: '4px', padding: '4px 8px', }); diff --git a/packages/component/src/components/page-list/type.ts b/packages/component/src/components/page-list/type.ts index 04b80856d1..c09418a83b 100644 --- a/packages/component/src/components/page-list/type.ts +++ b/packages/component/src/components/page-list/type.ts @@ -1,3 +1,5 @@ +import type { GetPageInfoById } from '@affine/env/page-info'; + /** * Get the keys of an object type whose values are of a given type * @@ -45,6 +47,7 @@ export type PageListProps = { onCreateNewPage: () => void; onCreateNewEdgeless: () => void; onImportFile: () => void; + getPageInfo: GetPageInfoById; }; export type DraggableTitleCellData = { diff --git a/packages/component/src/components/page-list/use-all-page-setting.ts b/packages/component/src/components/page-list/use-all-page-setting.ts index 45322552e1..c02187f861 100644 --- a/packages/component/src/components/page-list/use-all-page-setting.ts +++ b/packages/component/src/components/page-list/use-all-page-setting.ts @@ -1,29 +1,29 @@ -import type { Filter, VariableMap, View } from '@affine/env/filter'; +import type { Collection, Filter, VariableMap } from '@affine/env/filter'; import type { DBSchema } from 'idb'; import { openDB } from 'idb'; import type { IDBPDatabase } from 'idb/build/entry'; import { useAtom } from 'jotai'; -import { atomWithReset } from 'jotai/utils'; +import { atomWithReset, RESET } from 'jotai/utils'; import { useCallback } from 'react'; import useSWRImmutable from 'swr/immutable'; import { NIL } from 'uuid'; import { evalFilterList } from './filter'; -type PersistenceView = View; +type PersistenceCollection = Collection; -export interface PageViewDBV1 extends DBSchema { +export interface PageCollectionDBV1 extends DBSchema { view: { - key: PersistenceView['id']; - value: PersistenceView; + key: PersistenceCollection['id']; + value: PersistenceCollection; }; } -const pageViewDBPromise: Promise> = +const pageCollectionDBPromise: Promise> = typeof window === 'undefined' ? // never resolve in SSR new Promise(() => {}) - : openDB('page-view', 1, { + : openDB('page-view', 1, { upgrade(database) { database.createObjectStore('view', { keyPath: 'id', @@ -31,18 +31,24 @@ const pageViewDBPromise: Promise> = }, }); -const currentViewAtom = atomWithReset({ - name: 'default', - id: NIL, - filterList: [], +const collectionAtom = atomWithReset<{ + currentId: string; + defaultCollection: Collection; +}>({ + currentId: NIL, + defaultCollection: { + id: NIL, + name: 'All', + filterList: [], + }, }); -export const useAllPageSetting = () => { - const { data: savedViews, mutate } = useSWRImmutable( - ['affine', 'page-view'], +export const useSavedCollections = () => { + const { data: savedCollections, mutate } = useSWRImmutable( + ['affine', 'page-collection'], { fetcher: async () => { - const db = await pageViewDBPromise; + const db = await pageCollectionDBPromise; const t = db.transaction('view').objectStore('view'); return await t.getAll(); }, @@ -51,29 +57,98 @@ export const useAllPageSetting = () => { revalidateOnMount: true, } ); - - const [currentView, setCurrentView] = useAtom(currentViewAtom); - - const createView = useCallback( - async (view: View) => { - if (view.id === NIL) { + const saveCollection = useCallback( + async (collection: Collection) => { + if (collection.id === NIL) { return; } - const db = await pageViewDBPromise; + const db = await pageCollectionDBPromise; const t = db.transaction('view', 'readwrite').objectStore('view'); - await t.put(view); + await t.put(collection); await mutate(); }, [mutate] ); - + const deleteCollection = useCallback( + async (id: string) => { + if (id === NIL) { + return; + } + const db = await pageCollectionDBPromise; + const t = db.transaction('view', 'readwrite').objectStore('view'); + await t.delete(id); + await mutate(); + }, + [mutate] + ); + const addPage = useCallback( + async (collectionId: string, pageId: string) => { + const collection = savedCollections?.find(v => v.id === collectionId); + if (!collection) { + return; + } + await saveCollection({ + ...collection, + allowList: [pageId, ...(collection.allowList ?? [])], + }); + }, + [saveCollection, savedCollections] + ); return { - currentView, - savedViews: savedViews as View[], + savedCollections: savedCollections ?? [], + saveCollection, + deleteCollection, + addPage, + }; +}; + +export const useAllPageSetting = () => { + const { savedCollections, saveCollection, deleteCollection, addPage } = + useSavedCollections(); + const [collectionData, setCollectionData] = useAtom(collectionAtom); + + const updateCollection = useCallback( + async (collection: Collection) => { + if (collection.id === NIL) { + setCollectionData({ + ...collectionData, + defaultCollection: collection, + }); + } else { + await saveCollection(collection); + } + }, + [collectionData, saveCollection, setCollectionData] + ); + const selectCollection = useCallback( + (id: string) => { + setCollectionData({ + ...collectionData, + currentId: id, + }); + }, + [collectionData, setCollectionData] + ); + const backToAll = useCallback(() => { + setCollectionData(RESET); + }, [setCollectionData]); + const currentCollection = + collectionData.currentId === NIL + ? collectionData.defaultCollection + : savedCollections.find(v => v.id === collectionData.currentId) ?? + collectionData.defaultCollection; + return { + currentCollection: currentCollection, + savedCollections, + isDefault: currentCollection.id === NIL, // actions - createView, - setCurrentView, + saveCollection, + updateCollection, + selectCollection, + backToAll, + deleteCollection, + addPage, }; }; export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) => diff --git a/packages/component/src/components/page-list/view/collection-bar.css.ts b/packages/component/src/components/page-list/view/collection-bar.css.ts new file mode 100644 index 0000000000..be8283f6bd --- /dev/null +++ b/packages/component/src/components/page-list/view/collection-bar.css.ts @@ -0,0 +1,48 @@ +import { style } from '@vanilla-extract/css'; + +export const view = style({ + display: 'flex', + alignItems: 'center', + gap: 10, + fontSize: 14, + fontWeight: 600, + height: '100%', + paddingLeft: 16, +}); + +export const option = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 4, + cursor: 'pointer', + borderRadius: 4, + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, + opacity: 0, + selectors: { + [`${view}:hover &`]: { + opacity: 1, + }, + }, +}); +export const pin = style({ + opacity: 1, +}); +export const pinedIcon = style({ + display: 'block', + selectors: { + [`${option}:hover &`]: { + display: 'none', + }, + }, +}); +export const pinIcon = style({ + display: 'none', + selectors: { + [`${option}:hover &`]: { + display: 'block', + }, + }, +}); diff --git a/packages/component/src/components/page-list/view/collection-bar.tsx b/packages/component/src/components/page-list/view/collection-bar.tsx new file mode 100644 index 0000000000..f26b50c8b7 --- /dev/null +++ b/packages/component/src/components/page-list/view/collection-bar.tsx @@ -0,0 +1,126 @@ +import { EditCollectionModel } from '@affine/component/page-list'; +import type { GetPageInfoById } from '@affine/env/page-info'; +import { + DeleteIcon, + FilterIcon, + PinedIcon, + PinIcon, + UnpinIcon, + ViewLayersIcon, +} from '@blocksuite/icons'; +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { Button } from '../../../ui/button/button'; +import { useAllPageSetting } from '../use-all-page-setting'; +import * as styles from './collection-bar.css'; + +export const CollectionBar = ({ + getPageInfo, +}: { + getPageInfo: GetPageInfoById; +}) => { + const setting = useAllPageSetting(); + const collection = setting.currentCollection; + const [open, setOpen] = useState(false); + const actions: { + icon: ReactNode; + click: () => void; + className?: string; + name: string; + }[] = useMemo( + () => [ + { + icon: ( + <> + {collection.pinned ? ( + + ) : ( + + )} + {collection.pinned ? ( + + ) : ( + + )} + + ), + name: 'pin', + className: styles.pin, + click: () => { + return setting.updateCollection({ + ...collection, + pinned: !collection.pinned, + }); + }, + }, + { + icon: , + name: 'edit', + click: () => { + setOpen(true); + }, + }, + { + icon: , + name: 'delete', + click: () => { + setting.deleteCollection(collection.id).catch(err => { + console.error(err); + }); + }, + }, + ], + [setting, collection] + ); + const onClose = useCallback(() => setOpen(false), []); + return !setting.isDefault ? ( + + +
+ + +
+ {setting.currentCollection.name} +
+ {actions.map(action => { + return ( +
+ {action.icon} +
+ ); + })} +
+ + + + + + + + ) : null; +}; diff --git a/packages/component/src/components/page-list/view/collection-list.css.ts b/packages/component/src/components/page-list/view/collection-list.css.ts new file mode 100644 index 0000000000..f76851a683 --- /dev/null +++ b/packages/component/src/components/page-list/view/collection-list.css.ts @@ -0,0 +1,207 @@ +import { style } from '@vanilla-extract/css'; + +export const menuTitleStyle = style({ + marginLeft: '12px', + marginTop: '10px', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', +}); +export const menuDividerStyle = style({ + marginTop: '2px', + marginBottom: '2px', + marginLeft: '12px', + marginRight: '8px', + height: '1px', + background: 'var(--affine-border-color)', +}); +export const viewButton = style({ + borderRadius: '8px', + height: '100%', + padding: '4px 8px', + fontSize: 'var(--affine-font-xs)', + background: 'var(--affine-white)', + color: 'var(--affine-text-secondary-color)', + border: '1px solid var(--affine-border-color)', + transition: 'margin-left 0.2s ease-in-out', + ':hover': { + borderColor: 'var(--affine-border-color)', + background: 'var(--affine-hover-color)', + }, + marginRight: '20px', +}); +export const viewMenu = style({}); +export const viewOption = style({ + borderRadius: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginLeft: 6, + width: 24, + height: 24, + opacity: 0, + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, + selectors: { + [`${viewMenu}:hover &`]: { + opacity: 1, + }, + }, +}); +export const deleteOption = style({ + ':hover': { + backgroundColor: '#FFEFE9', + }, +}); +export const filterButton = style({ + borderRadius: '8px', + height: '100%', + padding: '4px 8px', + fontSize: 'var(--affine-font-xs)', + background: 'var(--affine-white)', + color: 'var(--affine-text-secondary-color)', + border: '1px solid var(--affine-border-color)', + transition: 'margin-left 0.2s ease-in-out', + ':hover': { + borderColor: 'var(--affine-border-color)', + background: 'var(--affine-hover-color)', + }, +}); +export const filterButtonCollapse = style({ + marginLeft: '20px', +}); +export const viewDivider = style({ + '::after': { + content: '""', + display: 'block', + width: '100%', + height: '1px', + background: 'var(--affine-border-color)', + position: 'absolute', + bottom: 0, + left: 0, + margin: '0 1px', + }, +}); +export const saveButton = style({ + marginTop: '4px', + borderRadius: '8px', + padding: '8px 0', + ':hover': { + background: 'var(--affine-hover-color)', + color: 'var(--affine-text-primary-color)', + border: '1px solid var(--affine-border-color)', + }, +}); +export const saveButtonContainer = style({ + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + width: '100%', + height: '100%', + padding: '8px', +}); +export const saveIcon = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-sm)', + marginRight: '8px', +}); +export const saveText = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 'var(--affine-font-sm)', +}); +export const cancelButton = style({ + background: 'var(--affine-hover-color)', + borderRadius: '8px', + ':hover': { + background: 'var(--affine-hover-color)', + color: 'var(--affine-text-primary-color)', + border: '1px solid var(--affine-border-color)', + }, +}); +export const saveTitle = style({ + fontSize: 'var(--affine-font-h-6)', + fontWeight: '600', + lineHeight: '24px', + paddingBottom: 20, +}); +export const allowList = style({}); + +export const allowTitle = style({ + fontSize: 12, + margin: '20px 0', +}); + +export const allowListContent = style({ + margin: '8px 0', +}); + +export const excludeList = style({ + backgroundColor: 'var(--affine-background-warning-color)', + padding: 18, + borderRadius: 8, +}); + +export const excludeListContent = style({ + margin: '8px 0', +}); + +export const filterTitle = style({ + fontSize: 12, + fontWeight: 600, + marginBottom: 10, +}); + +export const excludeTitle = style({ + fontSize: 12, + fontWeight: 600, +}); + +export const excludeTip = style({ + color: 'var(--affine-text-secondary-color)', + fontSize: 12, +}); + +export const scrollContainer = style({ + overflow: 'hidden', + flex: 1, + display: 'flex', + flexDirection: 'column', +}); +export const container = style({ + display: 'flex', + flexDirection: 'column', +}); +export const pageContainer = style({ + fontSize: 14, + fontWeight: 600, + height: 32, + display: 'flex', + alignItems: 'center', + paddingLeft: 8, + paddingRight: 5, +}); + +export const pageIcon = style({ + marginRight: 20, + display: 'flex', + alignItems: 'center', +}); + +export const pageTitle = style({ + flex: 1, +}); +export const deleteIcon = style({ + marginLeft: 20, + display: 'flex', + alignItems: 'center', + borderRadius: 4, + padding: 4, + cursor: 'pointer', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, +}); diff --git a/packages/component/src/components/page-list/view/collection-list.tsx b/packages/component/src/components/page-list/view/collection-list.tsx new file mode 100644 index 0000000000..0fa848ffda --- /dev/null +++ b/packages/component/src/components/page-list/view/collection-list.tsx @@ -0,0 +1,232 @@ +import { EditCollectionModel } from '@affine/component/page-list'; +import type { Collection, Filter } from '@affine/env/filter'; +import type { GetPageInfoById } from '@affine/env/page-info'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + DeleteIcon, + FilteredIcon, + FilterIcon, + FolderIcon, + PinIcon, + ViewLayersIcon, +} from '@blocksuite/icons'; +import clsx from 'clsx'; +import { useAtom } from 'jotai'; +import type { MouseEvent, ReactNode } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { Button, MenuItem } from '../../..'; +import Menu from '../../../ui/menu/menu'; +import { appSidebarOpenAtom } from '../../app-sidebar'; +import { CreateFilterMenu } from '../filter/vars'; +import type { useAllPageSetting } from '../use-all-page-setting'; +import * as styles from './collection-list.css'; + +const CollectionOption = ({ + collection, + setting, + updateCollection, +}: { + collection: Collection; + setting: ReturnType; + updateCollection: (view: Collection) => void; +}) => { + const actions: { + icon: ReactNode; + click: () => void; + className?: string; + name: string; + }[] = useMemo( + () => [ + { + icon: , + name: 'pin', + click: () => { + return setting.updateCollection({ + ...collection, + pinned: !collection.pinned, + }); + }, + }, + { + icon: , + name: 'edit', + click: () => { + updateCollection(collection); + }, + }, + { + icon: , + name: 'delete', + click: () => { + setting.deleteCollection(collection.id).catch(err => { + console.error(err); + }); + }, + }, + ], + [setting, updateCollection, collection] + ); + const selectCollection = useCallback( + () => setting.selectCollection(collection.id), + [setting, collection.id] + ); + return ( + } + onClick={selectCollection} + key={collection.id} + className={styles.viewMenu} + > +
+
{collection.name}
+
+ {actions.map((v, i) => { + const onClick = (e: MouseEvent) => { + e.stopPropagation(); + v.click(); + }; + return ( +
+ {v.icon} +
+ ); + })} +
+
+
+ ); +}; +export const CollectionList = ({ + setting, + getPageInfo, +}: { + setting: ReturnType; + getPageInfo: GetPageInfoById; +}) => { + const t = useAFFiNEI18N(); + const [open] = useAtom(appSidebarOpenAtom); + const [collection, setCollection] = useState(); + const onChange = useCallback( + (filterList: Filter[]) => { + return setting.updateCollection({ + ...setting.currentCollection, + filterList, + }); + }, + [setting] + ); + const closeUpdateCollectionModal = useCallback( + () => setCollection(undefined), + [] + ); + const onConfirm = useCallback( + (view: Collection) => { + return setting.updateCollection(view).then(() => { + closeUpdateCollectionModal(); + }); + }, + [closeUpdateCollectionModal, setting] + ); + return ( +
+ {setting.savedCollections.length > 0 && ( + + } + onClick={setting.backToAll} + className={styles.viewMenu} + > +
+
All
+
+
+
Saved Collection
+
+ {setting.savedCollections.map(view => ( + + ))} +
+ } + > + + + )} + + } + > + + + +
+ ); +}; diff --git a/packages/component/src/components/page-list/view/create-collection.tsx b/packages/component/src/components/page-list/view/create-collection.tsx new file mode 100644 index 0000000000..f8b166a287 --- /dev/null +++ b/packages/component/src/components/page-list/view/create-collection.tsx @@ -0,0 +1,291 @@ +import type { Collection } from '@affine/env/filter'; +import type { GetPageInfoById } from '@affine/env/page-info'; +import { + EdgelessIcon, + PageIcon, + RemoveIcon, + SaveIcon, +} from '@blocksuite/icons'; +import { useCallback, useState } from 'react'; + +import { + Button, + Input, + Modal, + ModalCloseButton, + ModalWrapper, + ScrollableContainer, +} from '../../..'; +import { FilterList } from '../filter'; +import * as styles from './collection-list.css'; + +type CreateCollectionProps = { + title?: string; + init: Collection; + onConfirm: (collection: Collection) => void; + onConfirmText?: string; + getPageInfo: GetPageInfoById; +}; +export const EditCollectionModel = ({ + init, + onConfirm, + open, + onClose, + getPageInfo, +}: { + init?: Collection; + onConfirm: (view: Collection) => void; + open: boolean; + onClose: () => void; + getPageInfo: GetPageInfoById; +}) => { + return ( + + + + {init ? ( + { + onConfirm(view); + onClose(); + }} + /> + ) : null} + + + ); +}; + +const Page = ({ + id, + onClick, + getPageInfo, +}: { + id: string; + onClick: (id: string) => void; + getPageInfo: GetPageInfoById; +}) => { + const page = getPageInfo(id); + if (!page) { + return null; + } + const icon = page.isEdgeless ? ( + + ) : ( + + ); + const click = () => { + onClick(id); + }; + return ( +
+
{icon}
+
{page.title}
+
+ +
+
+ ); +}; +export const EditCollection = ({ + title, + init, + onConfirm, + onCancel, + onConfirmText, + getPageInfo, +}: CreateCollectionProps & { + onCancel: () => void; +}) => { + const [value, onChange] = useState(init); + const removeFromExcludeList = useCallback( + (id: string) => { + onChange({ + ...value, + excludeList: value.excludeList?.filter(v => v !== id), + }); + }, + [value] + ); + const removeFromAllowList = useCallback( + (id: string) => { + onChange({ + ...value, + allowList: value.allowList?.filter(v => v !== id), + }); + }, + [value] + ); + return ( +
+
+ {title ?? 'Save As New Collection'} +
+ +
+
+ Exclude from this collection +
+ {value.excludeList ? ( +
+ {value.excludeList.map(id => { + return ( + + ); + })} +
+ ) : null} +
+ These pages will never appear in the current collection +
+
+
+
Filters
+ + onChange({ + ...value, + filterList: list, + }) + } + > + {value.allowList ? ( +
+
With follow pages:
+
+ {value.allowList.map(id => { + return ( + + ); + })} +
+
+ ) : null} +
+
+ + onChange({ + ...value, + name: text, + }) + } + /> +
+
+
+ + +
+
+ ); +}; +export const SaveCollectionButton = ({ + init, + onConfirm, + getPageInfo, +}: CreateCollectionProps) => { + const [show, changeShow] = useState(false); + return ( + <> + + changeShow(false)} + /> + + ); +}; diff --git a/packages/component/src/components/page-list/view/create-view.tsx b/packages/component/src/components/page-list/view/create-view.tsx deleted file mode 100644 index db5f7448e9..0000000000 --- a/packages/component/src/components/page-list/view/create-view.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { Filter, View } from '@affine/env/filter'; -import { SaveIcon } from '@blocksuite/icons'; -import { uuidv4 } from '@blocksuite/store'; -import { useState } from 'react'; - -import { Button, Input, Modal, ModalCloseButton, ModalWrapper } from '../../..'; -import { FilterList } from '../filter'; -import * as styles from './view-list.css'; - -type CreateViewProps = { - init: Filter[]; - onConfirm: (view: View) => void; -}; - -const CreateView = ({ - init, - onConfirm, - onCancel, -}: CreateViewProps & { onCancel: () => void }) => { - const [value, onChange] = useState({ - name: '', - filterList: init, - id: uuidv4(), - }); - - return ( -
-
Save As New View
-
- onChange({ ...value, filterList: list })} - > -
-
- onChange({ ...value, name: text })} - /> -
-
- - -
-
- ); -}; -export const SaveViewButton = ({ init, onConfirm }: CreateViewProps) => { - const [show, changeShow] = useState(false); - return ( - <> - - changeShow(false)}> - - changeShow(false)} - hoverColor="var(--affine-icon-color)" - /> - changeShow(false)} - onConfirm={view => { - onConfirm(view); - changeShow(false); - }} - /> - - - - ); -}; diff --git a/packages/component/src/components/page-list/view/index.ts b/packages/component/src/components/page-list/view/index.ts index a047ac03d3..de2ed02221 100644 --- a/packages/component/src/components/page-list/view/index.ts +++ b/packages/component/src/components/page-list/view/index.ts @@ -1,2 +1,3 @@ -export * from './create-view'; -export * from './view-list'; +export * from './collection-bar'; +export * from './collection-list'; +export * from './create-collection'; diff --git a/packages/component/src/components/page-list/view/view-list.css.ts b/packages/component/src/components/page-list/view/view-list.css.ts deleted file mode 100644 index bf24422003..0000000000 --- a/packages/component/src/components/page-list/view/view-list.css.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const filterButton = style({ - borderRadius: '8px', - height: '100%', - padding: '4px 8px', - fontSize: 'var(--affine-font-xs)', - background: 'var(--affine-white)', - color: 'var(--affine-text-secondary-color)', - border: '1px solid var(--affine-border-color)', - transition: 'margin-left 0.2s ease-in-out', - ':hover': { - borderColor: 'var(--affine-border-color)', - background: 'var(--affine-hover-color)', - }, -}); -export const filterButtonCollapse = style({ - marginLeft: '20px', -}); -export const viewDivider = style({ - '::after': { - content: '""', - display: 'block', - width: '100%', - height: '1px', - background: 'var(--affine-border-color)', - position: 'absolute', - bottom: 0, - left: 0, - margin: '0 1px', - }, -}); -export const saveButton = style({ - marginTop: '4px', - borderRadius: '8px', - padding: '8px 0', - ':hover': { - background: 'var(--affine-hover-color)', - color: 'var(--affine-text-primary-color)', - border: '1px solid var(--affine-border-color)', - }, -}); -export const saveButtonContainer = style({ - display: 'flex', - alignItems: 'center', - cursor: 'pointer', - width: '100%', - height: '100%', - padding: '8px', -}); -export const saveIcon = style({ - display: 'flex', - alignItems: 'center', - fontSize: 'var(--affine-font-sm)', - marginRight: '8px', -}); -export const saveText = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: 'var(--affine-font-sm)', -}); -export const cancelButton = style({ - background: 'var(--affine-hover-color)', - borderRadius: '8px', - ':hover': { - background: 'var(--affine-hover-color)', - color: 'var(--affine-text-primary-color)', - border: '1px solid var(--affine-border-color)', - }, -}); -export const saveTitle = style({ - fontSize: 'var(--affine-font-h-6)', - fontWeight: '600', - lineHeight: '24px', -}); diff --git a/packages/component/src/components/page-list/view/view-list.tsx b/packages/component/src/components/page-list/view/view-list.tsx deleted file mode 100644 index 5f282d9fcc..0000000000 --- a/packages/component/src/components/page-list/view/view-list.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { FilteredIcon } from '@blocksuite/icons'; -import clsx from 'clsx'; -import { useAtom } from 'jotai'; - -import { Button, MenuItem } from '../../..'; -import Menu from '../../../ui/menu/menu'; -import { appSidebarOpenAtom } from '../../app-sidebar'; -import { CreateFilterMenu } from '../filter/vars'; -import type { useAllPageSetting } from '../use-all-page-setting'; -import * as styles from './view-list.css'; -export const ViewList = ({ - setting, -}: { - setting: ReturnType; -}) => { - const [open] = useAtom(appSidebarOpenAtom); - const t = useAFFiNEI18N(); - return ( -
- {setting.savedViews.length > 0 && ( - - {setting.savedViews.map(view => { - return ( - setting.setCurrentView(view)} - key={view.id} - > - {view.name} - - ); - })} -
- } - > - - - )} - { - setting.setCurrentView(view => ({ - ...view, - filterList, - })); - }} - /> - } - > - - -
- ); -}; diff --git a/packages/component/src/ui/scrollbar/scrollbar.tsx b/packages/component/src/ui/scrollbar/scrollbar.tsx index 8806b3ef93..c100a74359 100644 --- a/packages/component/src/ui/scrollbar/scrollbar.tsx +++ b/packages/component/src/ui/scrollbar/scrollbar.tsx @@ -8,22 +8,28 @@ import * as styles from './index.css'; export type ScrollableContainerProps = { showScrollTopBorder?: boolean; inTableView?: boolean; + className?: string; + viewPortClassName?: string; }; export const ScrollableContainer = ({ children, showScrollTopBorder = false, inTableView = false, + className, + viewPortClassName, }: PropsWithChildren) => { const [hasScrollTop, ref] = useHasScrollTop(); return ( - +
{children}
diff --git a/packages/env/src/filter.ts b/packages/env/src/filter.ts index 2b0d80f44b..0b350e8949 100644 --- a/packages/env/src/filter.ts +++ b/packages/env/src/filter.ts @@ -25,8 +25,11 @@ export type Filter = { args: Literal[]; }; -export type View = { +export type Collection = { id: string; name: string; + pinned?: boolean; filterList: Filter[]; + allowList?: string[]; + excludeList?: string[]; }; diff --git a/packages/env/src/page-info.ts b/packages/env/src/page-info.ts new file mode 100644 index 0000000000..b23cf16c26 --- /dev/null +++ b/packages/env/src/page-info.ts @@ -0,0 +1,7 @@ +export type PageInfo = { + isEdgeless: boolean; + title: string; + id: string; +}; + +export type GetPageInfoById = (id: string) => PageInfo | undefined; diff --git a/packages/env/src/workspace.ts b/packages/env/src/workspace.ts index 05361c8db0..9abcf60580 100644 --- a/packages/env/src/workspace.ts +++ b/packages/env/src/workspace.ts @@ -7,7 +7,7 @@ import type { } from '@blocksuite/store'; import type { FC, PropsWithChildren } from 'react'; -import type { View } from './filter'; +import type { Collection } from './filter'; import type { Workspace as RemoteWorkspace } from './workspace/legacy-cloud'; export enum WorkspaceVersion { @@ -185,7 +185,7 @@ type PageDetailProps = type PageListProps<_Flavour extends keyof WorkspaceRegistry> = { blockSuiteWorkspace: BlockSuiteWorkspace; onOpenPage: (pageId: string, newTab?: boolean) => void; - view: View; + collection: Collection; }; export interface WorkspaceUISchema { diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index c9bdca9dc7..3ec6c6e05c 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -193,6 +193,7 @@ "Check Our Docs": "Check Our Docs", "Get in touch! Join our communities": "Get in touch! Join our communities.", "Favorites": "Favourites", + "Collections": "Collections", "Download data": "Download {{CoreOrAll}} data", "Back Home": "Back Home", "Set a Workspace name": "Set a Workspace name", diff --git a/tests/libs/page-logic.ts b/tests/libs/page-logic.ts index 8c1840aca7..e414b5c883 100644 --- a/tests/libs/page-logic.ts +++ b/tests/libs/page-logic.ts @@ -49,3 +49,9 @@ export async function clickPageMoreActions(page: Page) { .getByTestId('editor-option-menu') .click(); } + +export const closeDownloadTip = async (page: Page) => { + await page + .locator('[data-testid="download-client-tip-close-button"]') + .click(); +}; diff --git a/tests/parallels/all-page.spec.ts b/tests/parallels/all-page.spec.ts index 26959ecd4a..b20ec3847b 100644 --- a/tests/parallels/all-page.spec.ts +++ b/tests/parallels/all-page.spec.ts @@ -3,7 +3,11 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { openHomePage } from '../libs/load-page'; -import { getBlockSuiteEditorTitle, waitEditorLoad } from '../libs/page-logic'; +import { + closeDownloadTip, + getBlockSuiteEditorTitle, + waitEditorLoad, +} from '../libs/page-logic'; import { clickSideBarAllPageButton } from '../libs/sidebar'; function getAllPage(page: Page) { @@ -52,12 +56,6 @@ test('all page can create new edgeless page', async ({ page }) => { await expect(page.locator('affine-edgeless-page')).toBeVisible(); }); -const closeDownloadTip = async (page: Page) => { - await page - .locator('[data-testid="download-client-tip-close-button"]') - .click(); -}; - const createFirstFilter = async (page: Page, name: string) => { await page .locator('[data-testid="editor-header-items"]') diff --git a/tests/parallels/local-first-collections-items.spec.ts b/tests/parallels/local-first-collections-items.spec.ts new file mode 100644 index 0000000000..d696eacb5a --- /dev/null +++ b/tests/parallels/local-first-collections-items.spec.ts @@ -0,0 +1,103 @@ +import { test } from '@affine-test/kit/playwright'; +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import { openHomePage } from '../libs/load-page'; +import { + closeDownloadTip, + getBlockSuiteEditorTitle, + newPage, + waitEditorLoad, +} from '../libs/page-logic'; + +const createAndPinCollection = async ( + page: Page, + options?: { + collectionName?: string; + } +) => { + await openHomePage(page); + await waitEditorLoad(page); + await newPage(page); + await getBlockSuiteEditorTitle(page).click(); + await getBlockSuiteEditorTitle(page).fill('test page'); + await page.getByTestId('all-pages').click(); + const cell = page.getByRole('cell', { + name: 'test page', + }); + await expect(cell).toBeVisible(); + await closeDownloadTip(page); + await page.getByTestId('create-first-filter').click(); + await page + .getByTestId('variable-select') + .locator('button', { hasText: 'Created' }) + .click(); + await page.getByTestId('save-as-collection').click(); + const title = page.getByTestId('input-collection-title'); + await title.isVisible(); + await title.fill(options?.collectionName ?? 'test collection'); + await page.getByTestId('save-collection').click(); + await page.getByTestId('collection-bar-option-pin').click(); +}; +test('Show collections items in sidebar', async ({ page }) => { + await createAndPinCollection(page); + const collections = page.getByTestId('collections'); + const items = collections.getByTestId('collection-item'); + expect(await items.count()).toBe(1); + const first = items.first(); + expect(await first.textContent()).toBe('test collection'); + await first.getByTestId('fav-collapsed-button').click(); + const collectionPage = collections.getByTestId('collection-page').nth(1); + expect(await collectionPage.textContent()).toBe('test page'); + await collectionPage.getByTestId('collection-page-options').click(); + const deletePage = page + .getByTestId('collection-page-option') + .getByText('Delete'); + await deletePage.click(); + expect(await collections.getByTestId('collection-page').count()).toBe(1); + await first.getByTestId('collection-options').click(); + const deleteCollection = page + .getByTestId('collection-option') + .getByText('Delete'); + await deleteCollection.click(); + expect(await items.count()).toBe(0); +}); + +test('pin and unpin collection', async ({ page }) => { + const name = 'asd'; + await createAndPinCollection(page, { collectionName: name }); + const collections = page.getByTestId('collections'); + const items = collections.getByTestId('collection-item'); + expect(await items.count()).toBe(1); + const first = items.first(); + await first.getByTestId('collection-options').click(); + const deleteCollection = page + .getByTestId('collection-option') + .getByText('Unpin'); + await deleteCollection.click(); + expect(await items.count()).toBe(0); + await page.getByTestId('collection-select').click(); + const option = page.locator('[data-testid=collection-select-option]', { + hasText: name, + }); + await option.hover(); + await option.getByTestId('collection-select-option-pin').click(); + expect(await items.count()).toBe(1); +}); + +test('edit collection', async ({ page }) => { + await createAndPinCollection(page); + const collections = page.getByTestId('collections'); + const items = collections.getByTestId('collection-item'); + expect(await items.count()).toBe(1); + const first = items.first(); + await first.getByTestId('collection-options').click(); + const editCollection = page + .getByTestId('collection-option') + .getByText('Edit Filter'); + await editCollection.click(); + const title = page.getByTestId('input-collection-title'); + await title.fill('123'); + await page.getByTestId('save-collection').click(); + expect(await first.textContent()).toBe('123'); +}); diff --git a/yarn.lock b/yarn.lock index a87bb7f126..e517e87a0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -89,7 +89,7 @@ __metadata: "@blocksuite/blocks": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/editor": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/global": 0.0.0-20230629103121-76e6587d-nightly - "@blocksuite/icons": ^2.1.21 + "@blocksuite/icons": ^2.1.23 "@blocksuite/lit": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/store": 0.0.0-20230629103121-76e6587d-nightly "@dnd-kit/core": ^6.0.8 @@ -455,7 +455,7 @@ __metadata: "@blocksuite/blocks": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/editor": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/global": 0.0.0-20230629103121-76e6587d-nightly - "@blocksuite/icons": ^2.1.21 + "@blocksuite/icons": ^2.1.23 "@blocksuite/lit": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/store": 0.0.0-20230629103121-76e6587d-nightly "@storybook/addon-actions": ^7.0.23 @@ -513,7 +513,7 @@ __metadata: "@blocksuite/blocks": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/editor": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/global": 0.0.0-20230629103121-76e6587d-nightly - "@blocksuite/icons": ^2.1.21 + "@blocksuite/icons": ^2.1.23 "@blocksuite/lit": 0.0.0-20230629103121-76e6587d-nightly "@blocksuite/store": 0.0.0-20230629103121-76e6587d-nightly "@dnd-kit/core": ^6.0.8 @@ -3967,13 +3967,13 @@ __metadata: languageName: node linkType: hard -"@blocksuite/icons@npm:^2.1.21": - version: 2.1.21 - resolution: "@blocksuite/icons@npm:2.1.21" +"@blocksuite/icons@npm:^2.1.23": + version: 2.1.24 + resolution: "@blocksuite/icons@npm:2.1.24" peerDependencies: "@types/react": ^18.0.25 react: ^18.2.0 - checksum: ade86c53243691da1aae2bf2abca88b0d9594590a59cf30ec361cba8cb4268737e7129fc0a61ad87e610d709e3eb3d10c8fea3bb76beeeebb334dd14f1001ea1 + checksum: 170d060e194a923edc5733563fee54475b088235a344073c57608883c48c54d647bbcb33635dd3d816c4a498468e30acc16c9bb5dc5d515050a5636ead5f7679 languageName: node linkType: hard