diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 54f6b7a3cb..47516d5c7d 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -106,4 +106,7 @@ export const setPageModeAtom = atom( export type PageModeOption = 'all' | 'page' | 'edgeless'; export const allPageModeSelectAtom = atom('all'); +export type AllPageFilterOption = 'docs' | 'collections' | 'tags'; +export const allPageFilterSelectAtom = atom('docs'); + export const openWorkspaceListModalAtom = atom(false); diff --git a/packages/frontend/core/src/commands/affine-navigation.tsx b/packages/frontend/core/src/commands/affine-navigation.tsx index 625788c035..8974d917c7 100644 --- a/packages/frontend/core/src/commands/affine-navigation.tsx +++ b/packages/frontend/core/src/commands/affine-navigation.tsx @@ -17,13 +17,11 @@ export function registerAffineNavigationCommands({ store, workspace, navigationHelper, - pageListMode, setPageListMode, }: { t: ReturnType; store: ReturnType; navigationHelper: ReturnType; - pageListMode: PageModeOption; setPageListMode: React.Dispatch>; workspace: Workspace; }) { @@ -43,32 +41,26 @@ export function registerAffineNavigationCommands({ unsubs.push( registerAffineCommand({ - id: 'affine:goto-page-list', + id: 'affine:goto-collection-list', category: 'affine:navigation', icon: , - preconditionStrategy: () => { - return pageListMode !== 'page'; - }, - label: t['com.affine.cmdk.affine.navigation.goto-page-list'](), + label: 'Go to Collection List', run() { - navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - setPageListMode('page'); + navigationHelper.jumpToCollections(workspace.id); + setPageListMode('all'); }, }) ); unsubs.push( registerAffineCommand({ - id: 'affine:goto-edgeless-list', + id: 'affine:goto-tag-list', category: 'affine:navigation', icon: , - preconditionStrategy: () => { - return pageListMode !== 'edgeless'; - }, - label: t['com.affine.cmdk.affine.navigation.goto-edgeless-list'](), + label: 'Go to Tag List', run() { - navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - setPageListMode('edgeless'); + navigationHelper.jumpToTags(workspace.id); + setPageListMode('all'); }, }) ); diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts b/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts new file mode 100644 index 0000000000..a9dff28a9c --- /dev/null +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts @@ -0,0 +1,29 @@ +import { style } from '@vanilla-extract/css'; + +export const collectionListHeader = style({ + height: 100, + alignItems: 'center', + padding: '48px 16px 20px 24px', + overflow: 'hidden', + display: 'flex', + justifyContent: 'space-between', + background: 'var(--affine-background-primary-color)', +}); + +export const collectionListHeaderTitle = style({ + fontSize: 'var(--affine-font-h-5)', + fontWeight: 500, + color: 'var(--affine-text-secondary-color)', + display: 'flex', + alignItems: 'center', + gap: 8, +}); + +export const newCollectionButton = style({ + padding: '6px 10px', + borderRadius: '8px', + background: 'var(--affine-background-primary-color)', + fontSize: 'var(--affine-font-sm)', + fontWeight: 600, + height: '32px', +}); diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-header.tsx b/packages/frontend/core/src/components/page-list/collections/collection-list-header.tsx new file mode 100644 index 0000000000..2cafde00bb --- /dev/null +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-header.tsx @@ -0,0 +1,29 @@ +import { Button } from '@affine/component'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { ReactElement } from 'react'; + +import * as styles from './collection-list-header.css'; + +export const CollectionListHeader = ({ + node, + onCreate, +}: { + node: ReactElement | null; + onCreate: () => void; +}) => { + const t = useAFFiNEI18N(); + + return ( + <> +
+
+ {t['com.affine.collections.header']()} +
+ +
+ {node} + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/page-list-item.css.ts b/packages/frontend/core/src/components/page-list/collections/collection-list-item.css.ts similarity index 100% rename from packages/frontend/core/src/components/page-list/page-list-item.css.ts rename to packages/frontend/core/src/components/page-list/collections/collection-list-item.css.ts diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx b/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx new file mode 100644 index 0000000000..2edea5dc8a --- /dev/null +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx @@ -0,0 +1,206 @@ +import { Checkbox } from '@affine/component'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useDraggable } from '@dnd-kit/core'; +import { type PropsWithChildren, useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; + +import type { + CollectionListItemProps, + DraggableTitleCellData, + PageListItemProps, +} from '../types'; +import { ColWrapper, stopPropagation } from '../utils'; +import * as styles from './collection-list-item.css'; + +const ListTitleCell = ({ + title, + preview, +}: Pick) => { + const t = useAFFiNEI18N(); + return ( +
+
+ {title || t['Untitled']()} +
+ {preview ? ( +
+ {preview} +
+ ) : null} +
+ ); +}; + +const ListIconCell = ({ icon }: Pick) => { + return ( +
+ {icon} +
+ ); +}; + +const CollectionSelectionCell = ({ + selectable, + onSelectedChange, + selected, +}: Pick< + CollectionListItemProps, + 'selectable' | 'onSelectedChange' | 'selected' +>) => { + const onSelectionChange = useCallback( + (_event: React.ChangeEvent) => { + return onSelectedChange?.(); + }, + [onSelectedChange] + ); + if (!selectable) { + return null; + } + return ( +
+ +
+ ); +}; + +const CollectionListOperationsCell = ({ + operations, +}: Pick) => { + return operations ? ( +
+ {operations} +
+ ) : null; +}; + +export const CollectionListItem = (props: CollectionListItemProps) => { + const collectionTitleElement = useMemo(() => { + return ( +
+
+ + +
+
+ ); + }, [props.icon, props.onSelectedChange, props.selectable, props.selected]); + + // TODO: use getDropItemId + const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + id: 'collection-list-item-title-' + props.collectionId, + data: { + pageId: props.collectionId, + pageTitle: collectionTitleElement, + } satisfies DraggableTitleCellData, + disabled: !props.draggable, + }); + + return ( + + + +
+ + +
+ +
+ +
+ {props.operations ? ( + + + + ) : null} +
+ ); +}; + +type collectionListWrapperProps = PropsWithChildren< + Pick< + CollectionListItemProps, + 'to' | 'collectionId' | 'onClick' | 'draggable' + > & { + isDragging: boolean; + } +>; + +function CollectionListItemWrapper({ + to, + isDragging, + collectionId, + onClick, + children, + draggable, +}: collectionListWrapperProps) { + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (onClick) { + stopPropagation(e); + onClick(); + } + }, + [onClick] + ); + + const commonProps = useMemo( + () => ({ + 'data-testid': 'collection-list-item', + 'data-collection-id': collectionId, + 'data-draggable': draggable, + className: styles.root, + 'data-clickable': !!onClick || !!to, + 'data-dragging': isDragging, + onClick: handleClick, + }), + [collectionId, draggable, isDragging, onClick, to, handleClick] + ); + + if (to) { + return ( + + {children} + + ); + } else { + return
{children}
; + } +} diff --git a/packages/frontend/core/src/components/page-list/collections/index.ts b/packages/frontend/core/src/components/page-list/collections/index.ts new file mode 100644 index 0000000000..afed0c1444 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/collections/index.ts @@ -0,0 +1,3 @@ +export * from './collection-list-header'; +export * from './collection-list-item'; +export * from './virtualized-collection-list'; diff --git a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx new file mode 100644 index 0000000000..7ef7d5024f --- /dev/null +++ b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx @@ -0,0 +1,151 @@ +import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; +import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info'; +import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; +import { Trans } from '@affine/i18n'; +import { useAtomValue } from 'jotai'; +import { + type ReactElement, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; + +import { ListFloatingToolbar } from '../components/list-floating-toolbar'; +import { collectionHeaderColsDef } from '../header-col-def'; +import { CollectionOperationCell } from '../operation-cell'; +import { CollectionListItemRenderer } from '../page-group'; +import { ListTableHeader } from '../page-header'; +import type { CollectionMeta, ItemListHandle, ListItem } from '../types'; +import { useCollectionManager } from '../use-collection-manager'; +import type { AllPageListConfig } from '../view'; +import { VirtualizedList } from '../virtualized-list'; +import { CollectionListHeader } from './collection-list-header'; + +const useCollectionOperationsRenderer = ({ + info, + setting, + config, +}: { + info: DeleteCollectionInfo; + config: AllPageListConfig; + setting: ReturnType; +}) => { + const pageOperationsRenderer = useCallback( + (collection: Collection) => { + return ( + + ); + }, + [config, info, setting] + ); + + return pageOperationsRenderer; +}; + +export const VirtualizedCollectionList = ({ + collections, + collectionMetas, + setHideHeaderCreateNewCollection, + node, + handleCreateCollection, + config, +}: { + collections: Collection[]; + collectionMetas: CollectionMeta[]; + config: AllPageListConfig; + node: ReactElement | null; + handleCreateCollection: () => void; + setHideHeaderCreateNewCollection: (hide: boolean) => void; +}) => { + const listRef = useRef(null); + const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + const [selectedCollectionIds, setSelectedCollectionIds] = useState( + [] + ); + const setting = useCollectionManager(collectionsCRUDAtom); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const info = useDeleteCollectionInfo(); + + const collectionOperations = useCollectionOperationsRenderer({ + info, + setting, + config, + }); + + const filteredSelectedCollectionIds = useMemo(() => { + const ids = collections.map(collection => collection.id); + return selectedCollectionIds.filter(id => ids.includes(id)); + }, [collections, selectedCollectionIds]); + + const hideFloatingToolbar = useCallback(() => { + listRef.current?.toggleSelectable(); + }, []); + + const collectionOperationRenderer = useCallback( + (item: ListItem) => { + const collection = item as CollectionMeta; + return collectionOperations(collection); + }, + [collectionOperations] + ); + + const collectionHeaderRenderer = useCallback(() => { + return ; + }, []); + + const collectionItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + const handleDelete = useCallback(() => { + return setting.deleteCollection(info, ...selectedCollectionIds); + }, [setting, info, selectedCollectionIds]); + + return ( + <> + + } + selectedIds={filteredSelectedCollectionIds} + onSelectedIdsChange={setSelectedCollectionIds} + items={collectionMetas} + itemRenderer={collectionItemRenderer} + rowAsLink + blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + operationsRenderer={collectionOperationRenderer} + headerRenderer={collectionHeaderRenderer} + /> + 0} + content={ + +
+ {{ count: selectedCollectionIds.length } as any} +
+ selected +
+ } + onClose={hideFloatingToolbar} + onDelete={handleDelete} + /> + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/components/floating-toobar.tsx b/packages/frontend/core/src/components/page-list/components/floating-toolbar.tsx similarity index 100% rename from packages/frontend/core/src/components/page-list/components/floating-toobar.tsx rename to packages/frontend/core/src/components/page-list/components/floating-toolbar.tsx diff --git a/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.css.ts b/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.css.ts new file mode 100644 index 0000000000..3f68e4a83e --- /dev/null +++ b/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const floatingToolbar = style({ + position: 'absolute', + bottom: 26, + width: '100%', + zIndex: 1, +}); diff --git a/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx b/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx new file mode 100644 index 0000000000..98ad6b4b81 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/components/list-floating-toolbar.tsx @@ -0,0 +1,31 @@ +import { CloseIcon, DeleteIcon } from '@blocksuite/icons'; +import type { ReactNode } from 'react'; + +import { FloatingToolbar } from './floating-toolbar'; +import * as styles from './list-floating-toolbar.css'; + +export const ListFloatingToolbar = ({ + content, + onClose, + open, + onDelete, +}: { + open: boolean; + content: ReactNode; + onClose: () => void; + onDelete: () => void; +}) => { + return ( + + {content} + } /> + + } + type="danger" + data-testid="list-toolbar-delete" + /> + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/components/list-header-cell.css.ts b/packages/frontend/core/src/components/page-list/components/list-header-cell.css.ts new file mode 100644 index 0000000000..1edfac3296 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/components/list-header-cell.css.ts @@ -0,0 +1,30 @@ +import { style } from '@vanilla-extract/css'; + +export const headerCell = style({ + padding: '0 8px', + userSelect: 'none', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', + selectors: { + '&[data-sorting], &:hover': { + color: 'var(--affine-text-primary-color)', + }, + '&[data-sortable]': { + cursor: 'pointer', + }, + '&:not(:last-child)': { + borderRight: '1px solid var(--affine-hover-color-filled)', + }, + }, + display: 'flex', + alignItems: 'center', + columnGap: '4px', + position: 'relative', + whiteSpace: 'nowrap', +}); + +export const headerCellSortIcon = style({ + display: 'inline-flex', + fontSize: 14, + color: 'var(--affine-icon-color)', +}); diff --git a/packages/frontend/core/src/components/page-list/components/list-header-cell.tsx b/packages/frontend/core/src/components/page-list/components/list-header-cell.tsx new file mode 100644 index 0000000000..1fbde3409f --- /dev/null +++ b/packages/frontend/core/src/components/page-list/components/list-header-cell.tsx @@ -0,0 +1,54 @@ +import { SortDownIcon, SortUpIcon } from '@blocksuite/icons'; +import { useCallback } from 'react'; + +import type { ColWrapperProps, ListItem } from '../types'; +import { ColWrapper } from '../utils'; +import * as styles from './list-header-cell.css'; + +type HeaderCellProps = ColWrapperProps & { + sortKey: keyof ListItem; + sortable?: boolean; + order?: 'asc' | 'desc'; + sorting?: boolean; + onSort?: (sortable?: boolean, sortKey?: keyof ListItem) => void; +}; + +export const ListHeaderCell = ({ + sortKey, + sortable, + order, + sorting, + onSort, + alignment, + flex, + style, + hideInSmallContainer, + children, +}: HeaderCellProps) => { + const handleClick = useCallback(() => { + if (sortable) { + onSort?.(sortable, sortKey); + } + }, [sortable, sortKey, onSort]); + + return ( + + {children} + {sorting ? ( +
+ {order === 'asc' ? : } +
+ ) : null} +
+ ); +}; diff --git a/packages/frontend/core/src/components/page-list/components/new-page-buttton.tsx b/packages/frontend/core/src/components/page-list/components/new-page-button.tsx similarity index 100% rename from packages/frontend/core/src/components/page-list/components/new-page-buttton.tsx rename to packages/frontend/core/src/components/page-list/components/new-page-button.tsx diff --git a/packages/frontend/core/src/components/page-list/docs/index.ts b/packages/frontend/core/src/components/page-list/docs/index.ts new file mode 100644 index 0000000000..a122e4f001 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/docs/index.ts @@ -0,0 +1,5 @@ +export * from './page-list-header'; +export * from './page-list-item'; +export * from './page-list-new-page-button'; +export * from './page-tags'; +export * from './virtualized-page-list'; diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts b/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts new file mode 100644 index 0000000000..792d1896c2 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts @@ -0,0 +1,71 @@ +import { style } from '@vanilla-extract/css'; + +export const docListHeader = style({ + height: 100, + alignItems: 'center', + padding: '48px 16px 20px 24px', + overflow: 'hidden', + display: 'flex', + justifyContent: 'space-between', + background: 'var(--affine-background-primary-color)', +}); + +export const docListHeaderTitle = style({ + fontSize: 'var(--affine-font-h-5)', + fontWeight: 500, + color: 'var(--affine-text-secondary-color)', + display: 'flex', + alignItems: 'center', + gap: 8, + height: '28px', +}); + +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 addPageButton = style({ + padding: '6px 10px', + borderRadius: '8px', + background: 'var(--affine-background-primary-color)', + fontSize: 'var(--affine-font-sm)', + fontWeight: 600, + height: '32px', +}); + +export const tagSticky = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '1px 8px', + color: 'var(--affine-text-primary-color)', + fontSize: 'var(--affine-font-xs)', + borderRadius: '10px', + columnGap: '4px', + border: '1px solid var(--affine-border-color)', + background: 'var(--affine-background-primary-color)', + maxWidth: '30vw', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + height: '22px', + lineHeight: '1.67em', +}); + +export const tagIndicator = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + flexShrink: 0, +}); + +export const tagLabel = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx new file mode 100644 index 0000000000..e5ef4836e3 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx @@ -0,0 +1,181 @@ +import { Button } from '@affine/component'; +import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; +import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import type { Collection, Tag } from '@affine/env/filter'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { ViewLayersIcon } from '@blocksuite/icons'; +import { nanoid } from 'nanoid'; +import { useCallback, useMemo } from 'react'; + +import { createTagFilter } from '../filter/utils'; +import { + createEmptyCollection, + useCollectionManager, +} from '../use-collection-manager'; +import { tagColorMap } from '../utils'; +import type { AllPageListConfig } from '../view/edit-collection/edit-collection'; +import { + useEditCollection, + useEditCollectionName, +} from '../view/use-edit-collection'; +import * as styles from './page-list-header.css'; +import { PageListNewPageButton } from './page-list-new-page-button'; + +export const PageListHeader = ({ workspaceId }: { workspaceId: string }) => { + const t = useAFFiNEI18N(); + const setting = useCollectionManager(collectionsCRUDAtom); + const { jumpToCollections } = useNavigateHelper(); + + const handleJumpToCollections = useCallback(() => { + jumpToCollections(workspaceId); + }, [jumpToCollections, workspaceId]); + + const title = useMemo(() => { + if (setting.isDefault) { + return t['com.affine.all-pages.header'](); + } + return ( + <> +
+ {t['com.affine.collections.header']()} / +
+
+ +
+
+ {setting.currentCollection.name} +
+ + ); + }, [ + handleJumpToCollections, + setting.currentCollection.name, + setting.isDefault, + t, + ]); + + return ( +
+
{title}
+ + {t['New Page']()} + +
+ ); +}; +export const CollectionPageListHeader = ({ + collection, + workspaceId, + config, +}: { + config: AllPageListConfig; + collection: Collection; + workspaceId: string; +}) => { + const t = useAFFiNEI18N(); + const setting = useCollectionManager(collectionsCRUDAtom); + const { jumpToCollections } = useNavigateHelper(); + + const handleJumpToCollections = useCallback(() => { + jumpToCollections(workspaceId); + }, [jumpToCollections, workspaceId]); + + const { updateCollection } = useCollectionManager(collectionsCRUDAtom); + const { node, open } = useEditCollection(config); + + const handleAddPage = useAsyncCallback(async () => { + const ret = await open({ ...collection }, 'page'); + updateCollection(ret); + }, [collection, open, updateCollection]); + + return ( + <> + {node} +
+
+
+ {t['com.affine.collections.header']()} / +
+
+ +
+
+ {setting.currentCollection.name} +
+
+ +
+ + ); +}; + +export const TagPageListHeader = ({ + tag, + workspaceId, +}: { + tag: Tag; + workspaceId: string; +}) => { + const t = useAFFiNEI18N(); + const { jumpToTags, jumpToCollection } = useNavigateHelper(); + const setting = useCollectionManager(collectionsCRUDAtom); + const { open, node } = useEditCollectionName({ + title: t['com.affine.editCollection.saveCollection'](), + showTips: true, + }); + + const handleJumpToTags = useCallback(() => { + jumpToTags(workspaceId); + }, [jumpToTags, workspaceId]); + + const saveToCollection = useCallback( + (collection: Collection) => { + setting.createCollection({ + ...collection, + filterList: [createTagFilter(tag.id)], + }); + jumpToCollection(workspaceId, collection.id); + }, + [setting, tag.id, jumpToCollection, workspaceId] + ); + const handleClick = useCallback(() => { + open('') + .then(name => { + return saveToCollection(createEmptyCollection(nanoid(), { name })); + }) + .catch(err => { + console.error(err); + }); + }, [open, saveToCollection]); + + return ( + <> + {node} +
+
+
+ {t['Tags']()} / +
+
+
+
{tag.value}
+
+
+ +
+ + ); +}; diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts b/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts new file mode 100644 index 0000000000..82cd0b6572 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts @@ -0,0 +1,181 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + color: 'var(--affine-text-primary-color)', + height: '54px', // 42 + 12 + flexShrink: 0, + width: '100%', + alignItems: 'stretch', + transition: 'background-color 0.2s, opacity 0.2s', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, + overflow: 'hidden', + cursor: 'default', + willChange: 'opacity', + selectors: { + '&[data-clickable=true]': { + cursor: 'pointer', + }, + }, +}); + +export const dragOverlay = style({ + display: 'flex', + alignItems: 'center', + zIndex: 1001, + cursor: 'grabbing', + maxWidth: '360px', + transition: 'transform 0.2s', + willChange: 'transform', + selectors: { + '&[data-over=true]': { + transform: 'scale(0.8)', + }, + }, +}); +export const dragPageItemOverlay = style({ + height: '54px', + borderRadius: '10px', + display: 'flex', + alignItems: 'center', + background: 'var(--affine-hover-color-filled)', + boxShadow: 'var(--affine-menu-shadow)', + maxWidth: '360px', + minWidth: '260px', +}); + +export const dndCell = style({ + position: 'relative', + marginLeft: -8, + height: '100%', + outline: 'none', + paddingLeft: 8, +}); + +globalStyle(`[data-draggable=true] ${dndCell}:before`, { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + left: 0, + width: 4, + height: 4, + transition: 'height 0.2s, opacity 0.2s', + backgroundColor: 'var(--affine-placeholder-color)', + borderRadius: '2px', + opacity: 0, + willChange: 'height, opacity', +}); + +globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, { + height: 12, + opacity: 1, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, { + opacity: 0.5, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, { + height: 32, + width: 2, + opacity: 1, +}); + +// todo: remove global style +globalStyle(`${root} > :first-child`, { + paddingLeft: '16px', +}); + +globalStyle(`${root} > :last-child`, { + paddingRight: '8px', +}); + +export const titleIconsWrapper = style({ + padding: '0 5px', + display: 'flex', + alignItems: 'center', + gap: '10px', +}); + +export const selectionCell = style({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + fontSize: 'var(--affine-font-h-3)', +}); + +export const titleCell = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + padding: '0 16px', + maxWidth: 'calc(100% - 64px)', + flex: 1, + whiteSpace: 'nowrap', +}); + +export const titleCellMain = style({ + overflow: 'hidden', + fontSize: 'var(--affine-font-sm)', + fontWeight: 600, + whiteSpace: 'nowrap', + flex: 1, + textOverflow: 'ellipsis', + alignSelf: 'stretch', +}); + +export const titleCellPreview = style({ + overflow: 'hidden', + color: 'var(--affine-text-secondary-color)', + fontSize: 'var(--affine-font-xs)', + flex: 1, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + alignSelf: 'stretch', +}); + +export const iconCell = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-h-3)', + color: 'var(--affine-icon-color)', + flexShrink: 0, +}); + +export const tagsCell = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', + padding: '0 8px', + height: '60px', + width: '100%', +}); + +export const dateCell = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', + flexShrink: 0, + flexWrap: 'nowrap', + padding: '0 8px', +}); + +export const actionsCellWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + flexShrink: 0, +}); + +export const operationsCell = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + columnGap: '6px', + flexShrink: 0, +}); diff --git a/packages/frontend/core/src/components/page-list/page-list-item.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx similarity index 93% rename from packages/frontend/core/src/components/page-list/page-list-item.tsx rename to packages/frontend/core/src/components/page-list/docs/page-list-item.tsx index 775b86be8d..2b02a63a63 100644 --- a/packages/frontend/core/src/components/page-list/page-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx @@ -4,12 +4,12 @@ import { useDraggable } from '@dnd-kit/core'; import { type PropsWithChildren, useCallback, useMemo } from 'react'; import { Link } from 'react-router-dom'; +import type { DraggableTitleCellData, PageListItemProps } from '../types'; +import { ColWrapper, formatDate, stopPropagation } from '../utils'; import * as styles from './page-list-item.css'; import { PageTags } from './page-tags'; -import type { DraggableTitleCellData, PageListItemProps } from './types'; -import { ColWrapper, formatDate, stopPropagation } from './utils'; -const PageListTitleCell = ({ +const ListTitleCell = ({ title, preview, }: Pick) => { @@ -34,7 +34,7 @@ const PageListTitleCell = ({ ); }; -const PageListIconCell = ({ icon }: Pick) => { +const ListIconCell = ({ icon }: Pick) => { return (
{icon} @@ -128,9 +128,9 @@ export const PageListItem = (props: PageListItemProps) => { selectable={props.selectable} selected={props.selected} /> - +
- +
); }, [ @@ -174,9 +174,9 @@ export const PageListItem = (props: PageListItemProps) => { selectable={props.selectable} selected={props.selected} /> - + - + diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.css.ts b/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.css.ts new file mode 100644 index 0000000000..ca86af65cf --- /dev/null +++ b/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.css.ts @@ -0,0 +1,6 @@ +import { style } from '@vanilla-extract/css'; + +export const newPageButtonLabel = style({ + display: 'flex', + alignItems: 'center', +}); diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx new file mode 100644 index 0000000000..17e9601ca4 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx @@ -0,0 +1,35 @@ +import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { useAtomValue } from 'jotai'; +import type { PropsWithChildren } from 'react'; + +import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; +import { NewPageButton } from '../components/new-page-button'; +import * as styles from './page-list-new-page-button.css'; + +export const PageListNewPageButton = ({ + className, + children, + size, + testId, +}: PropsWithChildren<{ + className?: string; + size?: 'small' | 'default'; + testId?: string; +}>) => { + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const { importFile, createEdgeless, createPage } = usePageHelper( + currentWorkspace.blockSuiteWorkspace + ); + return ( +
+ +
{children}
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/page-list/page-tags.css.ts b/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts similarity index 100% rename from packages/frontend/core/src/components/page-list/page-tags.css.ts rename to packages/frontend/core/src/components/page-list/docs/page-tags.css.ts diff --git a/packages/frontend/core/src/components/page-list/page-tags.tsx b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx similarity index 75% rename from packages/frontend/core/src/components/page-list/page-tags.tsx rename to packages/frontend/core/src/components/page-list/docs/page-tags.tsx index 994e91bdf9..6cdf11bf40 100644 --- a/packages/frontend/core/src/components/page-list/page-tags.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx @@ -5,8 +5,8 @@ import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; import { useMemo } from 'react'; +import { stopPropagation, tagColorMap } from '../utils'; import * as styles from './page-tags.css'; -import { stopPropagation } from './utils'; export interface PageTagsProps { tags: Tag[]; @@ -22,24 +22,7 @@ interface TagItemProps { style?: React.CSSProperties; } -// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx) -const tagColorMap = (color: string) => { - const mapping: Record = { - 'var(--affine-tag-red)': 'var(--affine-palette-line-red)', - 'var(--affine-tag-teal)': 'var(--affine-palette-line-green)', - 'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)', - 'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)', - 'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)', - 'var(--affine-tag-white)': 'var(--affine-palette-line-grey)', - 'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)', - 'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)', - 'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)', - 'var(--affine-tag-green)': 'var(--affine-palette-line-green)', - }; - return mapping[color] || color; -}; - -const TagItem = ({ tag, idx, mode, style }: TagItemProps) => { +export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => { return (
{ + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + 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 ( + + 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; +}; + +export const VirtualizedPageList = ({ + tag, + collection, + config, + listItem, + setHideHeaderCreateNewPage, +}: { + tag?: Tag; + collection?: Collection; + config?: AllPageListConfig; + listItem?: PageMeta[]; + setHideHeaderCreateNewPage: (hide: boolean) => void; +}) => { + const listRef = useRef(null); + const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + const [selectedPageIds, setSelectedPageIds] = useState([]); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const pageOperations = usePageOperationsRenderer(); + const { isPreferredEdgeless } = usePageHelper( + currentWorkspace.blockSuiteWorkspace + ); + + const filteredPageMetas = useFilteredPageMetas( + 'all', + pageMetas, + currentWorkspace.blockSuiteWorkspace + ); + const pageMetasToRender = useMemo(() => { + if (listItem) { + return listItem; + } + return filteredPageMetas; + }, [filteredPageMetas, listItem]); + + const filteredSelectedPageIds = useMemo(() => { + const ids = pageMetasToRender.map(page => page.id); + return selectedPageIds.filter(id => ids.includes(id)); + }, [pageMetasToRender, selectedPageIds]); + + const hideFloatingToolbar = useCallback(() => { + listRef.current?.toggleSelectable(); + }, []); + + const pageOperationRenderer = useCallback( + (item: ListItem) => { + const page = item as PageMeta; + return pageOperations(page); + }, + [pageOperations] + ); + + const pageHeaderRenderer = useCallback(() => { + return ; + }, []); + + const pageItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + const heading = useMemo(() => { + if (tag) { + return ; + } + if (collection && config) { + return ( + + ); + } + return ; + }, [collection, config, currentWorkspace.id, tag]); + + const { setTrashModal } = useTrashModalHelper( + currentWorkspace.blockSuiteWorkspace + ); + + const handleMultiDelete = useCallback(() => { + const pageNameMapping = Object.fromEntries( + pageMetas.map(meta => [meta.id, meta.title]) + ); + + const pageNames = filteredSelectedPageIds.map( + id => pageNameMapping[id] ?? '' + ); + setTrashModal({ + open: true, + pageIds: filteredSelectedPageIds, + pageTitles: pageNames, + }); + hideFloatingToolbar(); + }, [filteredSelectedPageIds, hideFloatingToolbar, pageMetas, setTrashModal]); + + return ( + <> + + 0} + onDelete={handleMultiDelete} + onClose={hideFloatingToolbar} + content={ + +
+ {{ count: filteredSelectedPageIds.length } as any} +
+ selected +
+ } + /> + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/header-col-def.tsx b/packages/frontend/core/src/components/page-list/header-col-def.tsx new file mode 100644 index 0000000000..60268b834f --- /dev/null +++ b/packages/frontend/core/src/components/page-list/header-col-def.tsx @@ -0,0 +1,68 @@ +import { Trans } from '@affine/i18n'; + +import { ListHeaderTitleCell } from './page-header'; +import type { HeaderColDef } from './types'; + +export const pageHeaderColsDef: HeaderColDef[] = [ + { + key: 'title', + content: , + flex: 6, + alignment: 'start', + sortable: true, + }, + { + key: 'tags', + content: , + flex: 3, + alignment: 'end', + }, + { + key: 'createDate', + content: , + flex: 1, + sortable: true, + alignment: 'end', + hideInSmallContainer: true, + }, + { + key: 'updatedDate', + content: , + flex: 1, + sortable: true, + alignment: 'end', + hideInSmallContainer: true, + }, + { + key: 'actions', + content: '', + flex: 1, + alignment: 'end', + }, +]; + +export const collectionHeaderColsDef: HeaderColDef[] = [ + { + key: 'title', + content: , + flex: 9, + alignment: 'start', + sortable: true, + }, +]; + +export const tagHeaderColsDef: HeaderColDef[] = [ + { + key: 'title', + content: , + flex: 8, + alignment: 'start', + sortable: true, + }, + { + key: 'actions', + content: '', + flex: 1, + alignment: 'end', + }, +]; diff --git a/packages/frontend/core/src/components/page-list/index.tsx b/packages/frontend/core/src/components/page-list/index.tsx index 6a500cb2a6..cc8887d1e4 100644 --- a/packages/frontend/core/src/components/page-list/index.tsx +++ b/packages/frontend/core/src/components/page-list/index.tsx @@ -1,14 +1,22 @@ +export * from './collections'; export * from './components/favorite-tag'; -export * from './components/floating-toobar'; -export * from './components/new-page-buttton'; +export * from './components/floating-toolbar'; +export * from './components/new-page-button'; +export * from './docs'; +export * from './docs/page-list-item'; +export * from './docs/page-tags'; export * from './filter'; +export * from './header-col-def'; +export * from './list'; export * from './operation-cell'; export * from './operation-menu-items'; -export * from './page-list'; -export * from './page-list-item'; -export * from './page-tags'; +export * from './page-group'; +export * from './page-header'; +export * from './tags'; export * from './types'; export * from './use-collection-manager'; +export * from './use-filtered-page-metas'; +export * from './use-tag-metas'; export * from './utils'; export * from './view'; -export * from './virtualized-page-list'; +export * from './virtualized-list'; diff --git a/packages/frontend/core/src/components/page-list/pages-to-page-group.tsx b/packages/frontend/core/src/components/page-list/items-to-item-group.tsx similarity index 57% rename from packages/frontend/core/src/components/page-list/pages-to-page-group.tsx rename to packages/frontend/core/src/components/page-list/items-to-item-group.tsx index cb272368c5..3e327f83e5 100644 --- a/packages/frontend/core/src/components/page-list/pages-to-page-group.tsx +++ b/packages/frontend/core/src/components/page-list/items-to-item-group.tsx @@ -1,72 +1,77 @@ import { Trans } from '@affine/i18n'; -import type { PageMeta } from '@blocksuite/store'; -import type { PageGroupDefinition, PageGroupProps } from './types'; +import type { ItemGroupDefinition, ItemGroupProps, ListItem } from './types'; import { type DateKey } from './types'; import { betweenDaysAgo, withinDaysAgo } from './utils'; // todo: optimize date matchers -const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [ +const getDateGroupDefinitions = ( + key: DateKey +): ItemGroupDefinition[] => [ { id: 'today', label: , - match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1), + match: item => + withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1), }, { id: 'yesterday', label: , - match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2), + match: item => + betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1, 2), }, { id: 'last7Days', label: , - match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7), + match: item => + betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 2, 7), }, { id: 'last30Days', label: , match: item => - betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30), + betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 7, 30), }, { id: 'moreThan30Days', label: , - match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30), + match: item => + !withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 30), }, ]; -const pageGroupDefinitions = { +const itemGroupDefinitions = { createDate: getDateGroupDefinitions('createDate'), updatedDate: getDateGroupDefinitions('updatedDate'), // add more here later // todo: some page group definitions maybe dynamic }; -export function pagesToPageGroups( - pages: PageMeta[], +export function itemsToItemGroups( + items: T[], key?: DateKey -): PageGroupProps[] { +): ItemGroupProps[] { if (!key) { return [ { id: 'all', - items: pages, - allItems: pages, + items: items, + allItems: items, }, ]; } // assume pages are already sorted, we will use the page order to determine the group order - const groupDefs = pageGroupDefinitions[key]; - const groups: PageGroupProps[] = []; + const groupDefs = itemGroupDefinitions[key]; + const groups: ItemGroupProps[] = []; - for (const page of pages) { + for (const item of items) { // for a single page, there could be multiple groups that it belongs to - const matchedGroups = groupDefs.filter(def => def.match(page)); + const matchedGroups = groupDefs.filter(def => def.match(item)); for (const groupDef of matchedGroups) { const group = groups.find(g => g.id === groupDef.id); if (group) { - group.items.push(page); + group.items.push(item); } else { const label = typeof groupDef.label === 'function' @@ -75,8 +80,8 @@ export function pagesToPageGroups( groups.push({ id: groupDef.id, label: label, - items: [page], - allItems: pages, + items: [item], + allItems: items, }); } } diff --git a/packages/frontend/core/src/components/page-list/list.css.ts b/packages/frontend/core/src/components/page-list/list.css.ts new file mode 100644 index 0000000000..568a81ddf3 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/list.css.ts @@ -0,0 +1,69 @@ +import { createContainer, style } from '@vanilla-extract/css'; + +import * as itemStyles from './docs/page-list-item.css'; + +export const listRootContainer = createContainer('list-root-container'); + +export const pageListScrollContainer = style({ + width: '100%', + flex: 1, +}); + +export const root = style({ + width: '100%', + maxWidth: '100%', + containerName: listRootContainer, + containerType: 'inline-size', + background: 'var(--affine-background-primary-color)', +}); + +export const groupsContainer = style({ + display: 'flex', + flexDirection: 'column', + rowGap: '16px', +}); + +export const heading = style({}); + +export const colWrapper = style({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + overflow: 'hidden', +}); + +export const hideInSmallContainer = style({ + '@container': { + [`${listRootContainer} (max-width: 800px)`]: { + selectors: { + '&[data-hide-item="true"]': { + display: 'none', + }, + }, + }, + }, +}); + +export const favoriteCell = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + flexShrink: 0, + opacity: 0, + selectors: { + [`&[data-favorite], ${itemStyles.root}:hover &`]: { + opacity: 1, + }, + }, +}); + +export const clearLinkStyle = style({ + color: 'inherit', + textDecoration: 'none', + ':visited': { + color: 'inherit', + }, + ':active': { + color: 'inherit', + }, +}); diff --git a/packages/frontend/core/src/components/page-list/page-list.tsx b/packages/frontend/core/src/components/page-list/list.tsx similarity index 70% rename from packages/frontend/core/src/components/page-list/page-list.tsx rename to packages/frontend/core/src/components/page-list/list.tsx index 58420f2593..93c4627f69 100644 --- a/packages/frontend/core/src/components/page-list/page-list.tsx +++ b/packages/frontend/core/src/components/page-list/list.tsx @@ -12,41 +12,42 @@ import { useRef, } from 'react'; -import { PageGroup } from './page-group'; -import { PageListTableHeader } from './page-header'; -import * as styles from './page-list.css'; +import { pageHeaderColsDef } from './header-col-def'; +import * as styles from './list.css'; +import { ItemGroup } from './page-group'; +import { ListTableHeader } from './page-header'; import { - pageGroupsAtom, - pageListPropsAtom, - PageListProvider, + groupsAtom, + listPropsAtom, + ListProvider, selectionStateAtom, useAtom, useAtomValue, useSetAtom, } from './scoped-atoms'; -import type { PageListHandle, PageListProps } from './types'; +import type { ItemListHandle, ListItem, ListProps } from './types'; /** * Given a list of pages, render a list of pages */ -export const PageList = forwardRef( - function PageList(props, ref) { +export const List = forwardRef>( + function List(props, ref) { return ( // push pageListProps to the atom so that downstream components can consume it // this makes sure pageListPropsAtom is always populated // @ts-expect-error fix type issues later - - - - - + + + + + ); } ); // when pressing ESC or double clicking outside of the page list, close the selection mode // todo: use jotai-effect instead but it seems it does not work with jotai-scope? -const usePageSelectionStateEffect = () => { +const useItemSelectionStateEffect = () => { const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); useEffect(() => { if ( @@ -96,23 +97,22 @@ const usePageSelectionStateEffect = () => { ]); }; -export const PageListInnerWrapper = memo( +export const ListInnerWrapper = memo( ({ handleRef, children, onSelectionActiveChange, ...props }: PropsWithChildren< - PageListProps & { handleRef: ForwardedRef } + ListProps & { handleRef: ForwardedRef } >) => { - const setPageListPropsAtom = useSetAtom(pageListPropsAtom); - const [selectionState, setPageListSelectionState] = - useAtom(selectionStateAtom); - usePageSelectionStateEffect(); + const setListPropsAtom = useSetAtom(listPropsAtom); + const [selectionState, setListSelectionState] = useAtom(selectionStateAtom); + useItemSelectionStateEffect(); useEffect(() => { - setPageListPropsAtom(props); - }, [props, setPageListPropsAtom]); + setListPropsAtom(props); + }, [props, setListPropsAtom]); useEffect(() => { onSelectionActiveChange?.(!!selectionState.selectionActive); @@ -123,41 +123,42 @@ export const PageListInnerWrapper = memo( () => { return { toggleSelectable: () => { - setPageListSelectionState(false); + setListSelectionState(false); }, }; }, - [setPageListSelectionState] + [setListSelectionState] ); return children; } ); -PageListInnerWrapper.displayName = 'PageListInnerWrapper'; +ListInnerWrapper.displayName = 'ListInnerWrapper'; + +const ListInner = (props: ListProps) => { + const groups = useAtomValue(groupsAtom); -const PageListInner = (props: PageListProps) => { - const groups = useAtomValue(pageGroupsAtom); const hideHeader = props.hideHeader; return (
- {!hideHeader ? : null} + {!hideHeader ? : null}
{groups.map(group => ( - + ))}
); }; -interface PageListScrollContainerProps { +interface ListScrollContainerProps { className?: string; style?: React.CSSProperties; } -export const PageListScrollContainer = forwardRef< +export const ListScrollContainer = forwardRef< HTMLDivElement, - PropsWithChildren + PropsWithChildren >(({ className, children, style }, ref) => { const containerRef = useRef(null); const hasScrollTop = useHasScrollTop(containerRef); @@ -188,4 +189,4 @@ export const PageListScrollContainer = forwardRef< ); }); -PageListScrollContainer.displayName = 'PageListScrollContainer'; +ListScrollContainer.displayName = 'ListScrollContainer'; diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 51aef06492..58a6f32e37 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -6,24 +6,34 @@ import { MenuItem, Tooltip, } from '@affine/component'; +import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { + DeleteIcon, DeletePermanentlyIcon, + EditIcon, FavoritedIcon, FavoriteIcon, + FilterIcon, MoreVerticalIcon, OpenInNewIcon, ResetIcon, } from '@blocksuite/icons'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Link } from 'react-router-dom'; import { FavoriteTag } from './components/favorite-tag'; +import * as styles from './list.css'; import { DisablePublicSharing, MoveToTrash } from './operation-menu-items'; -import * as styles from './page-list.css'; +import type { useCollectionManager } from './use-collection-manager'; import { ColWrapper, stopPropagationWithoutPrevent } from './utils'; +import { + type AllPageListConfig, + useEditCollection, + useEditCollectionName, +} from './view'; -export interface OperationCellProps { +export interface PageOperationCellProps { favorite: boolean; isPublic: boolean; link: string; @@ -32,14 +42,14 @@ export interface OperationCellProps { onDisablePublicSharing: () => void; } -export const OperationCell = ({ +export const PageOperationCell = ({ favorite, isPublic, link, onToggleFavoritePage, onRemoveToTrash, onDisablePublicSharing, -}: OperationCellProps) => { +}: PageOperationCellProps) => { const t = useAFFiNEI18N(); const [openDisableShared, setOpenDisableShared] = useState(false); const OperationMenu = ( @@ -178,3 +188,94 @@ export const TrashOperationCell = ({ ); }; + +export interface CollectionOperationCellProps { + collection: Collection; + info: DeleteCollectionInfo; + config: AllPageListConfig; + setting: ReturnType; +} + +export const CollectionOperationCell = ({ + collection, + config, + setting, + info, +}: CollectionOperationCellProps) => { + const t = useAFFiNEI18N(); + + const { open: openEditCollectionModal, node: editModal } = + useEditCollection(config); + + const { open: openEditCollectionNameModal, node: editNameModal } = + useEditCollectionName({ + title: t['com.affine.editCollection.renameCollection'](), + }); + + const handleEditName = useCallback(() => { + // use openRenameModal if it is in the sidebar collection list + openEditCollectionNameModal(collection.name) + .then(name => { + return setting.updateCollection({ ...collection, name }); + }) + .catch(err => { + console.error(err); + }); + }, [collection, openEditCollectionNameModal, setting]); + + const handleEdit = useCallback(() => { + openEditCollectionModal(collection) + .then(collection => { + return setting.updateCollection(collection); + }) + .catch(err => { + console.error(err); + }); + }, [setting, collection, openEditCollectionModal]); + + const handleDelete = useCallback(() => { + return setting.deleteCollection(info, collection.id); + }, [setting, info, collection]); + + return ( + <> + {editModal} + {editNameModal} + + + + + + + + + + + + + + + + } + type="danger" + > + {t['Delete']()} + + } + contentOptions={{ + align: 'end', + }} + > + + + + + + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index a3ce3bbf66..417e5c5ef6 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -8,6 +8,7 @@ import { PageIcon, TodayIcon, ToggleCollapseIcon, + ViewLayersIcon, } from '@blocksuite/icons'; import type { PageMeta, Workspace } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; @@ -15,21 +16,36 @@ import clsx from 'clsx'; import { selectAtom } from 'jotai/utils'; import { type MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { CollectionListItem } from './collections/collection-list-item'; +import { PageListItem } from './docs/page-list-item'; import { PagePreview } from './page-content-preview'; import * as styles from './page-group.css'; -import { PageListItem } from './page-list-item'; import { - pageGroupCollapseStateAtom, - pageListPropsAtom, + groupCollapseStateAtom, + listPropsAtom, selectionStateAtom, useAtom, useAtomValue, } from './scoped-atoms'; -import type { PageGroupProps, PageListItemProps, PageListProps } from './types'; +import { TagListItem } from './tags/tag-list-item'; +import type { + CollectionListItemProps, + CollectionMeta, + ItemGroupProps, + ListItem, + ListProps, + PageListItemProps, + TagListItemProps, + TagMeta, +} from './types'; import { shallowEqual } from './utils'; -export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => { - const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom); +export const ItemGroupHeader = ({ + id, + items, + label, +}: ItemGroupProps) => { + const [collapseState, setCollapseState] = useAtom(groupCollapseStateAtom); const collapsed = collapseState[id]; const onExpandedClicked: MouseEventHandler = useCallback( e => { @@ -42,9 +58,9 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => { const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); const selectedItems = useMemo(() => { - const selectedPageIds = selectionState.selectedPageIds ?? []; - return items.filter(item => selectedPageIds.includes(item.id)); - }, [items, selectionState.selectedPageIds]); + const selectedIds = selectionState.selectedIds ?? []; + return items.filter(item => selectedIds.includes(item.id)); + }, [items, selectionState.selectedIds]); const allSelected = selectedItems.length === items.length; @@ -53,7 +69,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => { setSelectionActive(true); const nonCurrentGroupIds = - selectionState.selectedPageIds?.filter( + selectionState.selectedIds?.filter( id => !items.map(item => item.id).includes(id) ) ?? []; @@ -61,7 +77,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => { ? nonCurrentGroupIds : [...nonCurrentGroupIds, ...items.map(item => item.id)]; - selectionState.onSelectedPageIdsChange?.(newSelectedPageIds); + selectionState.onSelectedIdsChange?.(newSelectedPageIds); }, [setSelectionActive, selectionState, allSelected, items]); const t = useAFFiNEI18N(); @@ -103,7 +119,11 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => { ) : null; }; -export const PageGroup = ({ id, items, label }: PageGroupProps) => { +export const ItemGroup = ({ + id, + items, + label, +}: ItemGroupProps) => { const [collapsed, setCollapsed] = useState(false); const onExpandedClicked: MouseEventHandler = useCallback(e => { e.stopPropagation(); @@ -112,16 +132,16 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => { }, []); const selectionState = useAtomValue(selectionStateAtom); const selectedItems = useMemo(() => { - const selectedPageIds = selectionState.selectedPageIds ?? []; - return items.filter(item => selectedPageIds.includes(item.id)); - }, [items, selectionState.selectedPageIds]); + const selectedIds = selectionState.selectedIds ?? []; + return items.filter(item => selectedIds.includes(item.id)); + }, [items, selectionState.selectedIds]); const onSelectAll = useCallback(() => { const nonCurrentGroupIds = - selectionState.selectedPageIds?.filter( + selectionState.selectedIds?.filter( id => !items.map(item => item.id).includes(id) ) ?? []; - selectionState.onSelectedPageIdsChange?.([ + selectionState.onSelectedIdsChange?.([ ...nonCurrentGroupIds, ...items.map(item => item.id), ]); @@ -164,7 +184,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
{items.map(item => ( - + ))}
@@ -177,32 +197,64 @@ const requiredPropNames = [ 'blockSuiteWorkspace', 'rowAsLink', 'isPreferredEdgeless', - 'pageOperationsRenderer', - 'selectedPageIds', - 'onSelectedPageIdsChange', + 'operationsRenderer', + 'selectedIds', + 'onSelectedIdsChange', 'draggable', ] as const; -type RequiredProps = Pick & { +type RequiredProps = Pick< + ListProps, + (typeof requiredPropNames)[number] +> & { selectable: boolean; }; -const listPropsAtom = selectAtom( - pageListPropsAtom, +const listsPropsAtom = selectAtom( + listPropsAtom, props => { return Object.fromEntries( requiredPropNames.map(name => [name, props[name]]) - ) as RequiredProps; + ) as RequiredProps; }, shallowEqual ); -export const PageMetaListItemRenderer = (pageMeta: PageMeta) => { - const props = useAtomValue(listPropsAtom); +export const PageListItemRenderer = (item: ListItem) => { + const props = useAtomValue(listsPropsAtom); const { selectionActive } = useAtomValue(selectionStateAtom); + const page = item as PageMeta; return ( + ); +}; + +export const CollectionListItemRenderer = (item: ListItem) => { + const props = useAtomValue(listsPropsAtom); + const { selectionActive } = useAtomValue(selectionStateAtom); + const collection = item as CollectionMeta; + return ( + + ); +}; + +export const TagListItemRenderer = (item: ListItem) => { + const props = useAtomValue(listsPropsAtom); + const { selectionActive } = useAtomValue(selectionStateAtom); + const tag = item as TagMeta; + return ( + boolean; + isPreferredEdgeless?: (id: string) => boolean; }) => { - const isEdgeless = isPreferredEdgeless(id); + const isEdgeless = isPreferredEdgeless ? isPreferredEdgeless(id) : false; const { isJournal } = useJournalInfoHelper(workspace, id); if (isJournal) { return ; @@ -241,61 +293,132 @@ const UnifiedPageIcon = ({ return isEdgeless ? : ; }; -function pageMetaToPageItemProp( - pageMeta: PageMeta, - props: RequiredProps +function pageMetaToListItemProp( + item: PageMeta, + props: RequiredProps ): PageListItemProps { - const toggleSelection = props.onSelectedPageIdsChange + const toggleSelection = props.onSelectedIdsChange ? () => { - assertExists(props.selectedPageIds); - const prevSelected = props.selectedPageIds.includes(pageMeta.id); + assertExists(props.selectedIds); + const prevSelected = props.selectedIds.includes(item.id); const shouldAdd = !prevSelected; const shouldRemove = prevSelected; if (shouldAdd) { - props.onSelectedPageIdsChange?.([ - ...props.selectedPageIds, - pageMeta.id, - ]); + props.onSelectedIdsChange?.([...props.selectedIds, item.id]); } else if (shouldRemove) { - props.onSelectedPageIdsChange?.( - props.selectedPageIds.filter(id => id !== pageMeta.id) + props.onSelectedIdsChange?.( + props.selectedIds.filter(id => id !== item.id) ); } } : undefined; const itemProps: PageListItemProps = { - pageId: pageMeta.id, - title: , + pageId: item.id, + title: , preview: ( - + ), - createDate: new Date(pageMeta.createDate), - updatedDate: pageMeta.updatedDate - ? new Date(pageMeta.updatedDate) - : undefined, + createDate: new Date(item.createDate), + updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined, to: props.rowAsLink && !props.selectable - ? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}` + ? `/workspace/${props.blockSuiteWorkspace.id}/${item.id}` : undefined, onClick: props.selectable ? toggleSelection : undefined, icon: ( ), tags: - pageMeta.tags + item.tags ?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace)) .filter((v): v is Tag => v != null) ?? [], - operations: props.pageOperationsRenderer?.(pageMeta), + operations: props.operationsRenderer?.(item), selectable: props.selectable, - selected: props.selectedPageIds?.includes(pageMeta.id), + selected: props.selectedIds?.includes(item.id), + onSelectedChange: toggleSelection, + draggable: props.draggable, + isPublicPage: !!item.isPublic, + }; + return itemProps; +} + +function collectionMetaToListItemProp( + item: CollectionMeta, + props: RequiredProps +): CollectionListItemProps { + const toggleSelection = props.onSelectedIdsChange + ? () => { + assertExists(props.selectedIds); + const prevSelected = props.selectedIds.includes(item.id); + const shouldAdd = !prevSelected; + const shouldRemove = prevSelected; + + if (shouldAdd) { + props.onSelectedIdsChange?.([...props.selectedIds, item.id]); + } else if (shouldRemove) { + props.onSelectedIdsChange?.( + props.selectedIds.filter(id => id !== item.id) + ); + } + } + : undefined; + const itemProps: CollectionListItemProps = { + collectionId: item.id, + title: item.title, + to: + props.rowAsLink && !props.selectable + ? `/workspace/${props.blockSuiteWorkspace.id}/collection/${item.id}` + : undefined, + onClick: props.selectable ? toggleSelection : undefined, + icon: , + operations: props.operationsRenderer?.(item), + selectable: props.selectable, + selected: props.selectedIds?.includes(item.id), + onSelectedChange: toggleSelection, + draggable: props.draggable, + }; + return itemProps; +} +function tagMetaToListItemProp( + item: TagMeta, + props: RequiredProps +): TagListItemProps { + const toggleSelection = props.onSelectedIdsChange + ? () => { + assertExists(props.selectedIds); + const prevSelected = props.selectedIds.includes(item.id); + const shouldAdd = !prevSelected; + const shouldRemove = prevSelected; + + if (shouldAdd) { + props.onSelectedIdsChange?.([...props.selectedIds, item.id]); + } else if (shouldRemove) { + props.onSelectedIdsChange?.( + props.selectedIds.filter(id => id !== item.id) + ); + } + } + : undefined; + const itemProps: TagListItemProps = { + tagId: item.id, + title: item.title, + to: + props.rowAsLink && !props.selectable + ? `/workspace/${props.blockSuiteWorkspace.id}/tag/${item.id}` + : undefined, + onClick: props.selectable ? toggleSelection : undefined, + color: item.color, + pageCount: item.pageCount, + operations: props.operationsRenderer?.(item), + selectable: props.selectable, + selected: props.selectedIds?.includes(item.id), onSelectedChange: toggleSelection, draggable: props.draggable, - isPublicPage: !!pageMeta.isPublic, }; return itemProps; } diff --git a/packages/frontend/core/src/components/page-list/page-header.css.ts b/packages/frontend/core/src/components/page-list/page-header.css.ts new file mode 100644 index 0000000000..84c31abeae --- /dev/null +++ b/packages/frontend/core/src/components/page-list/page-header.css.ts @@ -0,0 +1,40 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +export const headerTitleCell = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', +}); + +export const tableHeader = style({ + display: 'flex', + alignItems: 'center', + padding: '10px 6px 10px 16px', + position: 'sticky', + overflow: 'hidden', + zIndex: 1, + top: 0, + left: 0, + background: 'var(--affine-background-primary-color)', + transition: 'box-shadow 0.2s ease-in-out', + transform: 'translateY(-0.5px)', // fix sticky look through issue +}); + +globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, { + boxShadow: '0 1px var(--affine-border-color)', +}); + +export const headerTitleSelectionIconWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + fontSize: '16px', + selectors: { + [`${tableHeader}[data-selectable=toggle] &`]: { + width: 32, + }, + [`${tableHeader}[data-selection-active=true] &`]: { + width: 24, + }, + }, +}); diff --git a/packages/frontend/core/src/components/page-list/page-header.tsx b/packages/frontend/core/src/components/page-list/page-header.tsx index a1f7e39c75..7822500d58 100644 --- a/packages/frontend/core/src/components/page-list/page-header.tsx +++ b/packages/frontend/core/src/components/page-list/page-header.tsx @@ -1,84 +1,31 @@ import { Checkbox, type CheckboxProps } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons'; -import type { PageMeta } from '@blocksuite/store'; +import { MultiSelectIcon } from '@blocksuite/icons'; import clsx from 'clsx'; import { selectAtom } from 'jotai/utils'; -import { - type MouseEventHandler, - type ReactNode, - useCallback, - useMemo, -} from 'react'; +import { type MouseEventHandler, useCallback } from 'react'; -import * as styles from './page-list.css'; +import { ListHeaderCell } from './components/list-header-cell'; +import * as styles from './page-header.css'; import { - pageListHandlersAtom, - pageListPropsAtom, - pagesAtom, + itemsAtom, + listHandlersAtom, + listPropsAtom, selectionStateAtom, - showOperationsAtom, sorterAtom, useAtom, useAtomValue, } from './scoped-atoms'; -import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils'; - -export const PageListHeaderCell = (props: HeaderCellProps) => { - const [sorter, setSorter] = useAtom(sorterAtom); - const onClick: MouseEventHandler = useCallback(() => { - if (props.sortable && props.sortKey) { - setSorter({ - newSortKey: props.sortKey, - }); - } - }, [props.sortKey, props.sortable, setSorter]); - - const sorting = sorter.key === props.sortKey; - - return ( - - {props.children} - {sorting ? ( -
- {sorter.order === 'asc' ? : } -
- ) : null} -
- ); -}; - -type HeaderColDef = { - key: string; - content: ReactNode; - flex: ColWrapperProps['flex']; - alignment?: ColWrapperProps['alignment']; - sortable?: boolean; - hideInSmallContainer?: boolean; -}; - -type HeaderCellProps = ColWrapperProps & { - sortKey: keyof PageMeta; - sortable?: boolean; -}; +import type { HeaderColDef, ListItem } from './types'; +import { stopPropagation } from './utils'; // the checkbox on the header has three states: // when list selectable = true, the checkbox will be presented // when internal selection state is not enabled, it is a clickable that enables the selection state // when internal selection state is enabled, it is a checkbox that reflects the selection state -const PageListHeaderCheckbox = () => { +const ListHeaderCheckbox = () => { const [selectionState, setSelectionState] = useAtom(selectionStateAtom); - const pages = useAtomValue(pagesAtom); + const items = useAtomValue(itemsAtom); const onActivateSelection: MouseEventHandler = useCallback( e => { stopPropagation(e); @@ -86,13 +33,13 @@ const PageListHeaderCheckbox = () => { }, [setSelectionState] ); - const handlers = useAtomValue(pageListHandlersAtom); + const handlers = useAtomValue(listHandlersAtom); const onChange: NonNullable = useCallback( (e, checked) => { stopPropagation(e); - handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []); + handlers.onSelectedIdsChange?.(checked ? items.map(i => i.id) : []); }, - [handlers, pages] + [handlers, items] ); if (!selectionState.selectable) { @@ -109,11 +56,11 @@ const PageListHeaderCheckbox = () => { ) : ( 0 && - selectionState.selectedPageIds.length < pages.length + selectionState.selectedIds && + selectionState.selectedIds.length > 0 && + selectionState.selectedIds.length < items.length } onChange={onChange} /> @@ -122,64 +69,37 @@ const PageListHeaderCheckbox = () => { ); }; -const PageListHeaderTitleCell = () => { +export const ListHeaderTitleCell = () => { const t = useAFFiNEI18N(); return (
- + {t['Title']()}
); }; -const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader); +const hideHeaderAtom = selectAtom(listPropsAtom, props => props.hideHeader); // the table header for page list -export const PageListTableHeader = () => { - const t = useAFFiNEI18N(); - const showOperations = useAtomValue(showOperationsAtom); +export const ListTableHeader = ({ + headerCols, +}: { + headerCols: HeaderColDef[]; +}) => { + const [sorter, setSorter] = useAtom(sorterAtom); const hideHeader = useAtomValue(hideHeaderAtom); const selectionState = useAtomValue(selectionStateAtom); - const headerCols = useMemo(() => { - const cols: (HeaderColDef | boolean)[] = [ - { - key: 'title', - content: , - flex: 6, - alignment: 'start', - sortable: true, - }, - { - key: 'tags', - content: t['Tags'](), - flex: 3, - alignment: 'end', - }, - { - key: 'createDate', - content: t['Created'](), - flex: 1, - sortable: true, - alignment: 'end', - hideInSmallContainer: true, - }, - { - key: 'updatedDate', - content: t['Updated'](), - flex: 1, - sortable: true, - alignment: 'end', - hideInSmallContainer: true, - }, - showOperations && { - key: 'actions', - content: '', - flex: 1, - alignment: 'end', - }, - ]; - return cols.filter((def): def is HeaderColDef => !!def); - }, [t, showOperations]); + const onSort = useCallback( + (sortable?: boolean, sortKey?: keyof ListItem) => { + if (sortable && sortKey) { + setSorter({ + newSortKey: sortKey, + }); + } + }, + [setSorter] + ); if (hideHeader) { return false; @@ -193,17 +113,20 @@ export const PageListTableHeader = () => { > {headerCols.map(col => { return ( - {col.content} - + ); })}
diff --git a/packages/frontend/core/src/components/page-list/page-list.css.ts b/packages/frontend/core/src/components/page-list/page-list.css.ts deleted file mode 100644 index 2be091713f..0000000000 --- a/packages/frontend/core/src/components/page-list/page-list.css.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createContainer, globalStyle, style } from '@vanilla-extract/css'; - -import * as itemStyles from './page-list-item.css'; - -export const listRootContainer = createContainer('list-root-container'); - -export const pageListScrollContainer = style({ - width: '100%', - flex: 1, -}); - -export const root = style({ - width: '100%', - maxWidth: '100%', - containerName: listRootContainer, - containerType: 'inline-size', - background: 'var(--affine-background-primary-color)', -}); - -export const groupsContainer = style({ - display: 'flex', - flexDirection: 'column', - rowGap: '16px', -}); - -export const heading = style({}); - -export const tableHeader = style({ - display: 'flex', - alignItems: 'center', - padding: '10px 6px 10px 16px', - position: 'sticky', - overflow: 'hidden', - zIndex: 1, - top: 0, - left: 0, - background: 'var(--affine-background-primary-color)', - transition: 'box-shadow 0.2s ease-in-out', - transform: 'translateY(-0.5px)', // fix sticky look through issue -}); - -globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, { - boxShadow: '0 1px var(--affine-border-color)', -}); - -export const headerCell = style({ - padding: '0 8px', - userSelect: 'none', - fontSize: 'var(--affine-font-xs)', - color: 'var(--affine-text-secondary-color)', - selectors: { - '&[data-sorting], &:hover': { - color: 'var(--affine-text-primary-color)', - }, - '&[data-sortable]': { - cursor: 'pointer', - }, - '&:not(:last-child)': { - borderRight: '1px solid var(--affine-hover-color-filled)', - }, - }, - display: 'flex', - alignItems: 'center', - columnGap: '4px', - position: 'relative', - whiteSpace: 'nowrap', -}); - -export const headerTitleCell = style({ - display: 'flex', - alignItems: 'center', - gap: '8px', -}); - -export const headerTitleSelectionIconWrapper = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-start', - fontSize: '16px', - selectors: { - [`${tableHeader}[data-selectable=toggle] &`]: { - width: 32, - }, - [`${tableHeader}[data-selection-active=true] &`]: { - width: 24, - }, - }, -}); - -export const headerCellSortIcon = style({ - display: 'inline-flex', - fontSize: 14, - color: 'var(--affine-icon-color)', -}); - -export const colWrapper = style({ - display: 'flex', - alignItems: 'center', - flexShrink: 0, - overflow: 'hidden', -}); - -export const hideInSmallContainer = style({ - '@container': { - [`${listRootContainer} (max-width: 800px)`]: { - display: 'none', - }, - }, -}); - -export const favoriteCell = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - flexShrink: 0, - opacity: 0, - selectors: { - [`&[data-favorite], ${itemStyles.root}:hover &`]: { - opacity: 1, - }, - }, -}); - -export const clearLinkStyle = style({ - color: 'inherit', - textDecoration: 'none', - ':visited': { - color: 'inherit', - }, - ':active': { - color: 'inherit', - }, -}); diff --git a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx index bed647ba8c..83c1b9ada8 100644 --- a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx +++ b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx @@ -1,22 +1,22 @@ import { DEFAULT_SORT_KEY } from '@affine/env/constant'; -import type { PageMeta } from '@blocksuite/store'; import { atom } from 'jotai'; import { selectAtom } from 'jotai/utils'; import { createIsolation } from 'jotai-scope'; -import { pagesToPageGroups } from './pages-to-page-group'; +import { itemsToItemGroups } from './items-to-item-group'; import type { - PageListProps, - PageMetaRecord, - VirtualizedPageListProps, + ListItem, + ListProps, + MetaRecord, + VirtualizedListProps, } from './types'; import { shallowEqual } from './utils'; // for ease of use in the component tree // note: must use selectAtom to access this atom for efficiency // @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms -export const pageListPropsAtom = atom< - PageListProps & Partial +export const listPropsAtom = atom< + ListProps & Partial> >(); // whether or not the table is in selection mode (showing selection checkbox & selection floating bar) @@ -25,13 +25,13 @@ const selectionActiveAtom = atom(false); export const selectionStateAtom = atom( get => { const baseAtom = selectAtom( - pageListPropsAtom, + listPropsAtom, props => { - const { selectable, selectedPageIds, onSelectedPageIdsChange } = props; + const { selectable, selectedIds, onSelectedIdsChange } = props; return { selectable, - selectedPageIds, - onSelectedPageIdsChange, + selectedIds, + onSelectedIdsChange, }; }, shallowEqual @@ -53,50 +53,49 @@ export const selectionStateAtom = atom( // id -> isCollapsed // maybe reset on page on unmount? -export const pageGroupCollapseStateAtom = atom>({}); +export const groupCollapseStateAtom = atom>({}); // get handlers from pageListPropsAtom -export const pageListHandlersAtom = selectAtom( - pageListPropsAtom, +export const listHandlersAtom = selectAtom( + listPropsAtom, props => { - const { onSelectedPageIdsChange } = props; + const { onSelectedIdsChange } = props; return { - onSelectedPageIdsChange, + onSelectedIdsChange, }; }, shallowEqual ); -export const pagesAtom = selectAtom( - pageListPropsAtom, - props => props.pages, +export const itemsAtom = selectAtom( + listPropsAtom, + props => props.items, shallowEqual ); export const showOperationsAtom = selectAtom( - pageListPropsAtom, - props => !!props.pageOperationsRenderer + listPropsAtom, + props => !!props.operationsRenderer ); -type SortingContext = { - key: T; +type SortingContext = { + key: KeyType; order: 'asc' | 'desc'; - fallbackKey?: T; + fallbackKey?: KeyType; }; -type SorterConfig = Record> = - { - key?: keyof T; - order: 'asc' | 'desc'; - sortingFn: (ctx: SortingContext, a: T, b: T) => number; - }; +type SorterConfig = { + key?: keyof T; + order: 'asc' | 'desc'; + sortingFn: (ctx: SortingContext, a: T, b: T) => number; +}; -const defaultSortingFn: SorterConfig['sortingFn'] = ( +const defaultSortingFn: SorterConfig>['sortingFn'] = ( ctx, a, b ) => { - const val = (obj: PageMetaRecord) => { + const val = (obj: MetaRecord) => { let v = obj[ctx.key]; if (v === undefined && ctx.fallbackKey) { v = obj[ctx.fallbackKey]; @@ -134,7 +133,14 @@ const defaultSortingFn: SorterConfig['sortingFn'] = ( return 0; }; -const sorterStateAtom = atom>({ +const validKeys: Array> = [ + 'id', + 'title', + 'createDate', + 'updatedDate', +]; + +const sorterStateAtom = atom>>({ key: DEFAULT_SORT_KEY, order: 'desc', sortingFn: defaultSortingFn, @@ -142,47 +148,45 @@ const sorterStateAtom = atom>({ export const sorterAtom = atom( get => { - let pages = get(pagesAtom); + let items = get(itemsAtom); const sorterState = get(sorterStateAtom); - const sortCtx: SortingContext | null = sorterState.key - ? { - key: sorterState.key, - order: sorterState.order, - } - : null; + const sortCtx: SortingContext> | null = + sorterState.key + ? { + key: sorterState.key, + order: sorterState.order, + } + : null; if (sortCtx) { if (sorterState.key === 'updatedDate') { sortCtx.fallbackKey = 'createDate'; } - const compareFn = (a: PageMetaRecord, b: PageMetaRecord) => + const compareFn = (a: MetaRecord, b: MetaRecord) => sorterState.sortingFn(sortCtx, a, b); - pages = [...pages].sort(compareFn); + items = [...items].sort(compareFn); } return { - pages, + items, ...sortCtx, }; }, - (_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => { + (_get, set, { newSortKey }: { newSortKey: keyof MetaRecord }) => { set(sorterStateAtom, sorterState => { - if (sorterState.key === newSortKey) { + if (validKeys.includes(newSortKey)) { return { ...sorterState, - order: sorterState.order === 'asc' ? 'desc' : 'asc', - }; - } else { - return { key: newSortKey, - order: 'desc', + order: sorterState.order === 'asc' ? 'desc' : 'asc', sortingFn: sorterState.sortingFn, }; } + return sorterState; }); } ); -export const pageGroupsAtom = atom(get => { - let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy)); +export const groupsAtom = atom(get => { + let groupBy = get(selectAtom(listPropsAtom, props => props.groupBy)); const sorter = get(sorterAtom); if (groupBy === false) { @@ -196,11 +200,11 @@ export const pageGroupsAtom = atom(get => { ? DEFAULT_SORT_KEY : undefined; } - return pagesToPageGroups(sorter.pages, groupBy); + return itemsToItemGroups(sorter.items, groupBy); }); export const { - Provider: PageListProvider, + Provider: ListProvider, useAtom, useAtomValue, useSetAtom, diff --git a/packages/frontend/core/src/components/page-list/tags/index.ts b/packages/frontend/core/src/components/page-list/tags/index.ts new file mode 100644 index 0000000000..11a1b31754 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/index.ts @@ -0,0 +1,3 @@ +export * from './tag-list-header'; +export * from './tag-list-item'; +export * from './virtualized-tag-list'; diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts b/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts new file mode 100644 index 0000000000..c475242514 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts @@ -0,0 +1,29 @@ +import { style } from '@vanilla-extract/css'; + +export const tagListHeader = style({ + height: 100, + alignItems: 'center', + padding: '48px 16px 20px 24px', + overflow: 'hidden', + display: 'flex', + justifyContent: 'space-between', + background: 'var(--affine-background-primary-color)', +}); + +export const tagListHeaderTitle = style({ + fontSize: 'var(--affine-font-h-5)', + fontWeight: 500, + color: 'var(--affine-text-secondary-color)', + display: 'flex', + alignItems: 'center', + gap: 8, +}); + +export const newTagButton = style({ + padding: '6px 10px', + borderRadius: '8px', + background: 'var(--affine-background-primary-color)', + fontSize: 'var(--affine-font-sm)', + fontWeight: 600, + height: '32px', +}); diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx b/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx new file mode 100644 index 0000000000..b225853867 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx @@ -0,0 +1,12 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; + +import * as styles from './tag-list-header.css'; + +export const TagListHeader = () => { + const t = useAFFiNEI18N(); + return ( +
+
{t['Tags']()}
+
+ ); +}; diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts new file mode 100644 index 0000000000..6cd9fd4c05 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts @@ -0,0 +1,187 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + color: 'var(--affine-text-primary-color)', + height: '54px', // 42 + 12 + flexShrink: 0, + width: '100%', + alignItems: 'stretch', + transition: 'background-color 0.2s, opacity 0.2s', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, + overflow: 'hidden', + cursor: 'default', + willChange: 'opacity', + selectors: { + '&[data-clickable=true]': { + cursor: 'pointer', + }, + }, +}); + +export const dragOverlay = style({ + display: 'flex', + alignItems: 'center', + zIndex: 1001, + cursor: 'grabbing', + maxWidth: '360px', + transition: 'transform 0.2s', + willChange: 'transform', + selectors: { + '&[data-over=true]': { + transform: 'scale(0.8)', + }, + }, +}); +export const dragPageItemOverlay = style({ + height: '54px', + borderRadius: '10px', + display: 'flex', + alignItems: 'center', + background: 'var(--affine-hover-color-filled)', + boxShadow: 'var(--affine-menu-shadow)', + maxWidth: '360px', + minWidth: '260px', +}); + +export const dndCell = style({ + position: 'relative', + marginLeft: -8, + height: '100%', + outline: 'none', + paddingLeft: 8, +}); + +globalStyle(`[data-draggable=true] ${dndCell}:before`, { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + left: 0, + width: 4, + height: 4, + transition: 'height 0.2s, opacity 0.2s', + backgroundColor: 'var(--affine-placeholder-color)', + borderRadius: '2px', + opacity: 0, + willChange: 'height, opacity', +}); + +globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, { + height: 12, + opacity: 1, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, { + opacity: 0.5, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, { + height: 32, + width: 2, + opacity: 1, +}); + +// todo: remove global style +globalStyle(`${root} > :first-child`, { + paddingLeft: '16px', +}); + +globalStyle(`${root} > :last-child`, { + paddingRight: '8px', +}); + +export const titleIconsWrapper = style({ + padding: '0 5px', + display: 'flex', + alignItems: 'center', + gap: '10px', +}); + +export const selectionCell = style({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + fontSize: 'var(--affine-font-h-3)', +}); + +export const titleCell = style({ + display: 'flex', + alignItems: 'flex-start', + padding: '0 16px', + maxWidth: 'calc(100% - 64px)', + flex: 1, + whiteSpace: 'nowrap', +}); + +export const titleCellMain = style({ + overflow: 'hidden', + fontSize: 'var(--affine-font-sm)', + fontWeight: 600, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + alignSelf: 'stretch', + paddingRight: '4px', +}); + +export const titleCellPreview = style({ + overflow: 'hidden', + color: 'var(--affine-text-secondary-color)', + fontSize: 'var(--affine-font-base)', + flex: 1, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + alignSelf: 'stretch', +}); + +export const iconCell = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-h-3)', + color: 'var(--affine-icon-color)', + flexShrink: 0, +}); + +export const tagsCell = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', + padding: '0 8px', + height: '60px', + width: '100%', +}); + +export const dateCell = style({ + display: 'flex', + alignItems: 'center', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', + flexShrink: 0, + flexWrap: 'nowrap', + padding: '0 8px', +}); + +export const actionsCellWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + flexShrink: 0, +}); + +export const operationsCell = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + columnGap: '6px', + flexShrink: 0, +}); + +export const tagIndicator = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + flexShrink: 0, +}); diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx new file mode 100644 index 0000000000..1e2bc739fd --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx @@ -0,0 +1,197 @@ +import { Checkbox } from '@affine/component'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useDraggable } from '@dnd-kit/core'; +import { type PropsWithChildren, useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; + +import type { DraggableTitleCellData, TagListItemProps } from '../types'; +import { ColWrapper, stopPropagation, tagColorMap } from '../utils'; +import * as styles from './tag-list-item.css'; + +const TagListTitleCell = ({ + title, + pageCount, +}: Pick) => { + const t = useAFFiNEI18N(); + return ( +
+
+ {title || t['Untitled']()} +
+
+ {`· ${pageCount} doc(s)`} +
+
+ ); +}; + +const ListIconCell = ({ color }: Pick) => { + return ( +
+ ); +}; + +const TagSelectionCell = ({ + selectable, + onSelectedChange, + selected, +}: Pick) => { + const onSelectionChange = useCallback( + (_event: React.ChangeEvent) => { + return onSelectedChange?.(); + }, + [onSelectedChange] + ); + if (!selectable) { + return null; + } + return ( +
+ +
+ ); +}; + +const TagListOperationsCell = ({ + operations, +}: Pick) => { + return operations ? ( +
+ {operations} +
+ ) : null; +}; + +export const TagListItem = (props: TagListItemProps) => { + const tagTitleElement = useMemo(() => { + return ( +
+
+ + +
+
+ ); + }, [props.color, props.onSelectedChange, props.selectable, props.selected]); + + // TODO: use getDropItemId + const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + id: 'tag-list-item-title-' + props.tagId, + data: { + pageId: props.tagId, + pageTitle: tagTitleElement, + } satisfies DraggableTitleCellData, + disabled: !props.draggable, + }); + + return ( + + + +
+ + +
+ +
+ +
+ {props.operations ? ( + + + + ) : null} +
+ ); +}; + +type TagListWrapperProps = PropsWithChildren< + Pick & { + isDragging: boolean; + } +>; + +function TagListItemWrapper({ + to, + isDragging, + tagId, + onClick, + children, + draggable, +}: TagListWrapperProps) { + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (onClick) { + stopPropagation(e); + onClick(); + } + }, + [onClick] + ); + + const commonProps = useMemo( + () => ({ + 'data-testid': 'tag-list-item', + 'data-tag-id': tagId, + 'data-draggable': draggable, + className: styles.root, + 'data-clickable': !!onClick || !!to, + 'data-dragging': isDragging, + onClick: handleClick, + }), + [tagId, draggable, isDragging, onClick, to, handleClick] + ); + + if (to) { + return ( + + {children} + + ); + } else { + return
{children}
; + } +} diff --git a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx new file mode 100644 index 0000000000..f50a63170d --- /dev/null +++ b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx @@ -0,0 +1,96 @@ +import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { Trans } from '@affine/i18n'; +import type { Tag } from '@blocksuite/store'; +import { useAtomValue } from 'jotai'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { ListFloatingToolbar } from '../components/list-floating-toolbar'; +import { tagHeaderColsDef } from '../header-col-def'; +import { TagListItemRenderer } from '../page-group'; +import { ListTableHeader } from '../page-header'; +import type { ItemListHandle, ListItem, TagMeta } from '../types'; +import { VirtualizedList } from '../virtualized-list'; +import { TagListHeader } from './tag-list-header'; + +export const VirtualizedTagList = ({ + tags, + tagMetas, + setHideHeaderCreateNewTag, + onTagDelete, +}: { + tags: Tag[]; + tagMetas: TagMeta[]; + setHideHeaderCreateNewTag: (hide: boolean) => void; + onTagDelete: (tagIds: string[]) => void; +}) => { + const listRef = useRef(null); + const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + + const filteredSelectedTagIds = useMemo(() => { + const ids = tags.map(tag => tag.id); + return selectedTagIds.filter(id => ids.includes(id)); + }, [selectedTagIds, tags]); + + const hideFloatingToolbar = useCallback(() => { + listRef.current?.toggleSelectable(); + }, []); + + const tagOperationRenderer = useCallback(() => { + return null; + }, []); + + const tagHeaderRenderer = useCallback(() => { + return ; + }, []); + + const tagItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + + const handleDelete = useCallback(() => { + onTagDelete(selectedTagIds); + hideFloatingToolbar(); + return; + }, [hideFloatingToolbar, onTagDelete, selectedTagIds]); + + return ( + <> + } + selectedIds={filteredSelectedTagIds} + onSelectedIdsChange={setSelectedTagIds} + items={tagMetas} + itemRenderer={tagItemRenderer} + rowAsLink + blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + operationsRenderer={tagOperationRenderer} + headerRenderer={tagHeaderRenderer} + /> + 0} + content={ + +
+ {{ count: selectedTagIds.length } as any} +
+ selected +
+ } + onClose={hideFloatingToolbar} + onDelete={handleDelete} + /> + + ); +}; diff --git a/packages/frontend/core/src/components/page-list/types.ts b/packages/frontend/core/src/components/page-list/types.ts index 1443f660d9..e820c96c2c 100644 --- a/packages/frontend/core/src/components/page-list/types.ts +++ b/packages/frontend/core/src/components/page-list/types.ts @@ -1,8 +1,24 @@ -import type { Tag } from '@affine/env/filter'; +import type { Collection, Tag } from '@affine/env/filter'; import type { PageMeta, Workspace } from '@blocksuite/store'; -import type { ReactNode } from 'react'; +import type { PropsWithChildren, ReactNode } from 'react'; import type { To } from 'react-router-dom'; +export type ListItem = PageMeta | CollectionMeta | TagMeta; + +export interface CollectionMeta extends Collection { + title: string; + createDate?: Date; + updatedDate?: Date; +} + +export type TagMeta = { + id: string; + title: string; + color: string; + pageCount?: number; + createDate?: Date; + updatedDate?: Date; +}; // TODO: consider reducing the number of props here // using type instead of interface to make it Record compatible export type PageListItemProps = { @@ -23,10 +39,41 @@ export type PageListItemProps = { onSelectedChange?: () => void; }; -export interface PageListHeaderProps {} +export type CollectionListItemProps = { + collectionId: string; + icon: JSX.Element; + title: ReactNode; // using ReactNode to allow for rich content rendering + createDate?: Date; + updatedDate?: Date; + to?: To; // whether or not to render this item as a Link + draggable?: boolean; // whether or not to allow dragging this item + selectable?: boolean; // show selection checkbox + selected?: boolean; + operations?: ReactNode; // operations to show on the right side of the item + onClick?: () => void; + onSelectedChange?: () => void; +}; + +export type TagListItemProps = { + tagId: string; + color: string; + title: ReactNode; // using ReactNode to allow for rich content rendering + pageCount?: number; + createDate?: Date; + updatedDate?: Date; + to?: To; // whether or not to render this item as a Link + draggable?: boolean; // whether or not to allow dragging this item + selectable?: boolean; // show selection checkbox + selected?: boolean; + operations?: ReactNode; // operations to show on the right side of the item + onClick?: () => void; + onSelectedChange?: () => void; +}; + +export interface ItemListHeaderProps {} // todo: a temporary solution. may need to be refactored later -export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later +export type ItemGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later // todo: a temporary solution. may need to be refactored later export interface SortBy { @@ -36,56 +83,75 @@ export interface SortBy { export type DateKey = 'createDate' | 'updatedDate'; -export interface PageListProps { +export interface ListProps { // required data: - pages: PageMeta[]; + items: T[]; blockSuiteWorkspace: Workspace; className?: string; hideHeader?: boolean; // whether or not to hide the header. default is false (showing header) - groupBy?: PagesGroupByType | false; - isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row + groupBy?: ItemGroupByType | false; + isPreferredEdgeless?: (pageId: string) => boolean; // determines the icon used for each row rowAsLink?: boolean; selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item - selectedPageIds?: string[]; // selected page ids - onSelectedPageIdsChange?: (selected: string[]) => void; + selectedIds?: string[]; // selected page ids + onSelectedIdsChange?: (selected: string[]) => void; onSelectionActiveChange?: (active: boolean) => void; draggable?: boolean; // whether or not to allow dragging this page item // we also need the following to make sure the page list functions properly // maybe we could also give a function to render PageListItem? - pageOperationsRenderer?: (page: PageMeta) => ReactNode; + operationsRenderer?: (item: T) => ReactNode; } -export interface VirtualizedPageListProps extends PageListProps { +export interface VirtualizedListProps extends ListProps { heading?: ReactNode; // the user provided heading part (non sticky, above the original header) + headerRenderer?: () => ReactNode; // the user provided header renderer + itemRenderer?: (item: T) => ReactNode; // the user provided item renderer atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0 atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not } -export interface PageListHandle { +export interface ItemListHandle { toggleSelectable: () => void; } -export interface PageGroupDefinition { +export interface ItemGroupDefinition { id: string; // using a function to render custom group header label: (() => ReactNode) | ReactNode; - match: (item: PageMeta) => boolean; + match: (item: T) => boolean; } -export interface PageGroupProps { +export interface ItemGroupProps { id: string; label?: ReactNode; // if there is no label, it is a default group (without header) - items: PageMeta[]; - allItems: PageMeta[]; + items: T[]; + allItems: T[]; } type MakeRecord = { [P in keyof T]: T[P]; }; -export type PageMetaRecord = MakeRecord; +export type MetaRecord = MakeRecord; export type DraggableTitleCellData = { pageId: string; pageTitle: ReactNode; }; + +export type HeaderColDef = { + key: string; + content: ReactNode; + flex: ColWrapperProps['flex']; + alignment?: ColWrapperProps['alignment']; + sortable?: boolean; + hideInSmallContainer?: boolean; +}; + +export type ColWrapperProps = PropsWithChildren<{ + flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + alignment?: 'start' | 'center' | 'end'; + styles?: React.CSSProperties; + hideInSmallContainer?: boolean; +}> & + React.HTMLAttributes; diff --git a/packages/frontend/core/src/pages/workspace/pages.tsx b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx similarity index 83% rename from packages/frontend/core/src/pages/workspace/pages.tsx rename to packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx index ecb689923b..0a17b50ff5 100644 --- a/packages/frontend/core/src/pages/workspace/pages.tsx +++ b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx @@ -1,16 +1,16 @@ -import { - filterPage, - filterPageByRules, - useCollectionManager, -} from '@affine/core/components/page-list'; +import { allPageModeSelectAtom } from '@affine/core/atoms'; +import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import type { BlockSuiteWorkspace } from '@affine/core/shared'; 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'; +import { + filterPage, + filterPageByRules, + useCollectionManager, +} from './use-collection-manager'; export const useFilteredPageMetas = ( route: 'all' | 'trash', diff --git a/packages/frontend/core/src/components/page-list/use-tag-metas.ts b/packages/frontend/core/src/components/page-list/use-tag-metas.ts new file mode 100644 index 0000000000..4e08f22bf1 --- /dev/null +++ b/packages/frontend/core/src/components/page-list/use-tag-metas.ts @@ -0,0 +1,100 @@ +import type { PageMeta, Tag, Workspace } from '@blocksuite/store'; +import { useCallback, useMemo } from 'react'; + +interface TagUsageCounts { + [key: string]: number; +} + +export function useTagMetas( + currentWorkspace: Workspace, + pageMetas: PageMeta[] +) { + const tags = useMemo(() => { + return currentWorkspace.meta.properties.tags?.options || []; + }, [currentWorkspace]); + + const [tagMetas, tagUsageCounts] = useMemo(() => { + const tagUsageCounts: TagUsageCounts = {}; + tags.forEach(tag => { + tagUsageCounts[tag.id] = 0; + }); + + pageMetas.forEach(page => { + if (!page.tags) { + return; + } + page.tags.forEach(tagId => { + if (Object.prototype.hasOwnProperty.call(tagUsageCounts, tagId)) { + tagUsageCounts[tagId]++; + } + }); + }); + + const tagsList = tags.map(tag => { + return { + ...tag, + title: tag.value, + color: tag.color, + pageCount: tagUsageCounts[tag.id] || 0, + }; + }); + + return [tagsList, tagUsageCounts]; + }, [tags, pageMetas]); + + const filterPageMetaByTag = useCallback( + (tagId: string) => { + return pageMetas.filter(page => { + return page.tags.includes(tagId); + }); + }, + [pageMetas] + ); + + const addNewTag = useCallback( + (tag: Tag) => { + const newTags = [...tags, tag]; + currentWorkspace.meta.setProperties({ + tags: { options: newTags }, + }); + }, + [currentWorkspace.meta, tags] + ); + + const updateTag = useCallback( + (tag: Tag) => { + const newTags = tags.map(t => { + if (t.id === tag.id) { + return tag; + } + return t; + }); + currentWorkspace.meta.setProperties({ + tags: { options: newTags }, + }); + }, + [currentWorkspace.meta, tags] + ); + + const deleteTags = useCallback( + (tagIds: string[]) => { + const newTags = tags.filter(tag => { + return !tagIds.includes(tag.id); + }); + currentWorkspace.meta.setProperties({ + tags: { options: newTags }, + }); + }, + [currentWorkspace.meta, tags] + ); + + return { + tags, + tagMetas, + tagUsageCounts, + filterPageMetaByTag, + addNewTag, + updateTag, + deleteTags, + }; +} diff --git a/packages/frontend/core/src/components/page-list/utils.tsx b/packages/frontend/core/src/components/page-list/utils.tsx index c0f29a9e10..64d6869878 100644 --- a/packages/frontend/core/src/components/page-list/utils.tsx +++ b/packages/frontend/core/src/components/page-list/utils.tsx @@ -1,11 +1,8 @@ import clsx from 'clsx'; -import { - type BaseSyntheticEvent, - forwardRef, - type PropsWithChildren, -} from 'react'; +import { type BaseSyntheticEvent, forwardRef } from 'react'; -import * as styles from './page-list.css'; +import * as styles from './list.css'; +import type { ColWrapperProps } from './types'; export function isToday(date: Date): boolean { const today = new Date(); @@ -71,14 +68,6 @@ export const formatDate = (date: Date): string => { return `${month}-${day} ${hours}:${minutes}`; }; -export type ColWrapperProps = PropsWithChildren<{ - flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; - alignment?: 'start' | 'center' | 'end'; - styles?: React.CSSProperties; - hideInSmallContainer?: boolean; -}> & - React.HTMLAttributes; - export const ColWrapper = forwardRef( function ColWrapper( { @@ -103,11 +92,10 @@ export const ColWrapper = forwardRef( flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto', justifyContent: alignment, }} - className={clsx( - className, - styles.colWrapper, - hideInSmallContainer ? styles.hideInSmallContainer : null - )} + data-hide-item={hideInSmallContainer ? true : undefined} + className={clsx(className, styles.colWrapper, { + [styles.hideInSmallContainer]: hideInSmallContainer, + })} > {children}
@@ -173,3 +161,20 @@ export function shallowEqual(objA: any, objB: any) { return true; } + +// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx) +export const tagColorMap = (color: string) => { + const mapping: Record = { + 'var(--affine-tag-red)': 'var(--affine-palette-line-red)', + 'var(--affine-tag-teal)': 'var(--affine-palette-line-green)', + 'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)', + 'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)', + 'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)', + 'var(--affine-tag-white)': 'var(--affine-palette-line-grey)', + 'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)', + 'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)', + 'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)', + 'var(--affine-tag-green)': 'var(--affine-palette-line-green)', + }; + return mapping[color] || color; +}; diff --git a/packages/frontend/core/src/components/page-list/view/collection-list.css.ts b/packages/frontend/core/src/components/page-list/view/collection-list.css.ts index 8e73a8d392..cc1b313557 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-list.css.ts +++ b/packages/frontend/core/src/components/page-list/view/collection-list.css.ts @@ -38,4 +38,9 @@ export const filterMenuTrigger = style({ ':hover': { backgroundColor: 'var(--affine-hover-color)', }, + selectors: { + [`&[data-is-hidden="true"]`]: { + display: 'none', + }, + }, }); diff --git a/packages/frontend/core/src/components/page-list/view/collection-list.tsx b/packages/frontend/core/src/components/page-list/view/collection-list.tsx index 2a99409c3b..ec4096df20 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-list.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-list.tsx @@ -25,11 +25,13 @@ export const CollectionList = ({ propertiesMeta, allPageListConfig, userInfo, + disable, }: { setting: ReturnType; propertiesMeta: PropertiesMeta; allPageListConfig: AllPageListConfig; userInfo: DeleteCollectionInfo; + disable?: boolean; }) => { const t = useAFFiNEI18N(); const [collection, setCollection] = useState(); @@ -72,6 +74,7 @@ export const CollectionList = ({ className={styles.filterMenuTrigger} type="default" icon={} + data-is-hidden={disable} data-testid="create-first-filter" > {t['com.affine.filter']()} diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx index f13d99599c..b0c707bba4 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx @@ -8,7 +8,11 @@ import { type ReactNode, useCallback } from 'react'; import { FilterList } from '../../filter/filter-list'; import { VariableSelect } from '../../filter/vars'; -import { VirtualizedPageList } from '../../virtualized-page-list'; +import { pageHeaderColsDef } from '../../header-col-def'; +import { PageListItemRenderer } from '../../page-group'; +import { ListTableHeader } from '../../page-header'; +import type { ListItem } from '../../types'; +import { VirtualizedList } from '../../virtualized-list'; import type { AllPageListConfig } from './edit-collection'; import * as styles from './edit-collection.css'; import { EmptyList } from './select-page'; @@ -46,9 +50,19 @@ export const PagesMode = ({ }); }, [collection, updateCollection]); const pageOperationsRenderer = useCallback( - (page: PageMeta) => allPageListConfig.favoriteRender(page), + (item: ListItem) => { + const page = item as PageMeta; + return allPageListConfig.favoriteRender(page); + }, [allPageListConfig] ); + + const pageItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + const pageHeaderRenderer = useCallback(() => { + return ; + }, []); return ( <> ) : null} {searchedList.length ? ( - { + onSelectedIdsChange={ids => { updateCollection({ ...collection, allowList: ids, }); }} - pageOperationsRenderer={pageOperationsRenderer} - selectedPageIds={collection.allowList} + itemRenderer={pageItemRenderer} + operationsRenderer={pageOperationsRenderer} + headerRenderer={pageHeaderRenderer} + selectedIds={collection.allowList} isPreferredEdgeless={allPageListConfig.isEdgeless} - > + /> ) : ( )} diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx index c7e4b009c3..1bdf1a1bf9 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx @@ -13,7 +13,8 @@ import clsx from 'clsx'; import { type ReactNode, useCallback, useEffect, useState } from 'react'; import { FilterList } from '../../filter'; -import { PageList, PageListScrollContainer } from '../../page-list'; +import { List, ListScrollContainer } from '../../list'; +import type { ListItem } from '../../types'; import { filterPageByRules } from '../../use-collection-manager'; import { AffineShapeIcon } from '../affine-shape'; import type { AllPageListConfig } from './edit-collection'; @@ -78,6 +79,13 @@ export const RulesMode = ({ const [expandInclude, setExpandInclude] = useState( collection.allowList.length > 0 ); + const operationsRenderer = useCallback( + (item: ListItem) => { + const page = item as PageMeta; + return allPageListConfig.favoriteRender(page); + }, + [allPageListConfig] + ); return ( <> {/*prevents modal autofocus to the first input*/} @@ -241,22 +249,22 @@ export const RulesMode = ({ ) : null} - {rulesPages.length > 0 ? ( - + operationsRenderer={operationsRenderer} + > ) : ( {t['com.affine.editCollection.rules.include.title']()} - + operationsRenderer={operationsRenderer} + > ) : null} - +
diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx index 7debf879c0..3974edb585 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx @@ -2,12 +2,14 @@ import { Button, Menu } from '@affine/component'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { FilterIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; import clsx from 'clsx'; import { useCallback, useState } from 'react'; import { FilterList } from '../../filter'; import { VariableSelect } from '../../filter/vars'; -import { VirtualizedPageList } from '../../virtualized-page-list'; +import type { ListItem } from '../../types'; +import { VirtualizedList } from '../../virtualized-list'; import { AffineShapeIcon } from '../affine-shape'; import type { AllPageListConfig } from './edit-collection'; import * as styles from './edit-collection.css'; @@ -43,6 +45,15 @@ export const SelectPage = ({ } = useFilter(allPageListConfig.allPages); const { searchText, updateSearchText, searchedList } = useSearch(filteredList); + + const operationsRenderer = useCallback( + (item: ListItem) => { + const page = item as PageMeta; + return allPageListConfig.favoriteRender(page); + }, + [allPageListConfig] + ); + return (
) : null} {searchedList.length ? ( - ) : ( diff --git a/packages/frontend/core/src/components/page-list/virtualized-page-list.tsx b/packages/frontend/core/src/components/page-list/virtualized-list.tsx similarity index 62% rename from packages/frontend/core/src/components/page-list/virtualized-page-list.tsx rename to packages/frontend/core/src/components/page-list/virtualized-list.tsx index baff371df5..87f94aa647 100644 --- a/packages/frontend/core/src/components/page-list/virtualized-page-list.tsx +++ b/packages/frontend/core/src/components/page-list/virtualized-list.tsx @@ -1,5 +1,4 @@ import { Scrollable } from '@affine/component'; -import type { PageMeta } from '@blocksuite/store'; import clsx from 'clsx'; import { selectAtom } from 'jotai/utils'; import { @@ -12,29 +11,29 @@ import { } from 'react'; import { Virtuoso } from 'react-virtuoso'; -import { PageGroupHeader, PageMetaListItemRenderer } from './page-group'; -import { PageListTableHeader } from './page-header'; -import { PageListInnerWrapper } from './page-list'; -import * as styles from './page-list.css'; +import { ListInnerWrapper } from './list'; +import * as styles from './list.css'; +import { ItemGroupHeader } from './page-group'; import { - pageGroupCollapseStateAtom, - pageGroupsAtom, - pageListPropsAtom, - PageListProvider, + groupCollapseStateAtom, + groupsAtom, + listPropsAtom, + ListProvider, useAtomValue, } from './scoped-atoms'; import type { - PageGroupProps, - PageListHandle, - VirtualizedPageListProps, + ItemGroupProps, + ItemListHandle, + ListItem, + VirtualizedListProps, } from './types'; // we have three item types for rendering rows in Virtuoso type VirtuosoItemType = | 'sticky-header' - | 'page-group-header' - | 'page-item' - | 'page-item-spacer'; + | 'group-header' + | 'item' + | 'item-spacer'; interface BaseVirtuosoItem { type: VirtuosoItemType; @@ -44,62 +43,62 @@ interface VirtuosoItemStickyHeader extends BaseVirtuosoItem { type: 'sticky-header'; } -interface VirtuosoItemPageItem extends BaseVirtuosoItem { - type: 'page-item'; - data: PageMeta; +interface VirtuosoItemItem extends BaseVirtuosoItem { + type: 'item'; + data: T; } -interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem { - type: 'page-group-header'; - data: PageGroupProps; +interface VirtuosoItemGroupHeader extends BaseVirtuosoItem { + type: 'group-header'; + data: ItemGroupProps; } interface VirtuosoPageItemSpacer extends BaseVirtuosoItem { - type: 'page-item-spacer'; + type: 'item-spacer'; data: { height: number; }; } -type VirtuosoItem = +type VirtuosoItem = | VirtuosoItemStickyHeader - | VirtuosoItemPageItem - | VirtuosoItemPageGroupHeader + | VirtuosoItemItem + | VirtuosoItemGroupHeader | VirtuosoPageItemSpacer; /** * Given a list of pages, render a list of pages * Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering) */ -export const VirtualizedPageList = forwardRef< - PageListHandle, - VirtualizedPageListProps ->(function VirtualizedPageList(props, ref) { +export const VirtualizedList = forwardRef< + ItemListHandle, + VirtualizedListProps +>(function VirtualizedList(props, ref) { return ( // push pageListProps to the atom so that downstream components can consume it // this makes sure pageListPropsAtom is always populated // @ts-expect-error fix type issues later - - - - - + + + + + ); }); -const headingAtom = selectAtom(pageListPropsAtom, props => props.heading); +const headingAtom = selectAtom(listPropsAtom, props => props.heading); const PageListHeading = () => { const heading = useAtomValue(headingAtom); return
{heading}
; }; -const useVirtuosoItems = () => { - const groups = useAtomValue(pageGroupsAtom); - const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom); +const useVirtuosoItems = () => { + const groups = useAtomValue(groupsAtom); + const groupCollapsedState = useAtomValue(groupCollapseStateAtom); return useMemo(() => { - const items: VirtuosoItem[] = []; + const items: VirtuosoItem[] = []; // 1. // always put sticky header at the top @@ -114,21 +113,21 @@ const useVirtuosoItems = () => { // skip empty group header since it will cause issue in virtuoso ("Zero-sized element") if (group.label) { items.push({ - type: 'page-group-header', - data: group, + type: 'group-header', + data: group as ItemGroupProps, }); } // do not render items if the group is collapsed if (!groupCollapsedState[group.id]) { for (const item of group.items) { items.push({ - type: 'page-item', - data: item, + type: 'item', + data: item as T, }); // add a spacer between items (4px), unless it's the last item if (item !== group.items[group.items.length - 1]) { items.push({ - type: 'page-item-spacer', + type: 'item-spacer', data: { height: 4, }, @@ -139,7 +138,7 @@ const useVirtuosoItems = () => { // add a spacer between groups (16px) items.push({ - type: 'page-item-spacer', + type: 'item-spacer', data: { height: 16, }, @@ -149,19 +148,6 @@ const useVirtuosoItems = () => { }, [groupCollapsedState, groups]); }; -const itemContentRenderer = (_index: number, data: VirtuosoItem) => { - switch (data.type) { - case 'sticky-header': - return ; - case 'page-group-header': - return ; - case 'page-item': - return ; - case 'page-item-spacer': - return
; - } -}; - const Scroller = forwardRef< HTMLDivElement, PropsWithChildren> @@ -178,12 +164,12 @@ const Scroller = forwardRef< Scroller.displayName = 'Scroller'; -const PageListInner = ({ +const ListInner = ({ atTopStateChange, atTopThreshold, ...props -}: VirtualizedPageListProps) => { - const virtuosoItems = useVirtuosoItems(); +}: VirtualizedListProps) => { + const virtuosoItems = useVirtuosoItems(); const [atTop, setAtTop] = useState(false); const handleAtTopStateChange = useCallback( (atTop: boolean) => { @@ -198,15 +184,30 @@ const PageListInner = ({ Scroller: Scroller, }; }, [props.heading]); + const itemContentRenderer = useCallback( + (_index: number, data: VirtuosoItem) => { + switch (data.type) { + case 'sticky-header': + return props.headerRenderer?.(); + case 'group-header': + return ; + case 'item': + return props.itemRenderer?.(data.data); + case 'item-spacer': + return
; + } + }, + [props] + ); return ( - + > data-has-scroll-top={!atTop} atTopThreshold={atTopThreshold ?? 0} atTopStateChange={handleAtTopStateChange} components={components} data={virtuosoItems} data-testid="virtualized-page-list" - data-total-count={props.pages.length} // for testing, since we do not know the total count in test + data-total-count={props.items.length} // for testing, since we do not know the total count in test topItemCount={1} // sticky header totalCount={virtuosoItems.length} itemContent={itemContentRenderer} diff --git a/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx b/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx index 61b8078c61..fa5f22fcd5 100644 --- a/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-mode-filter-tab/index.tsx @@ -1,30 +1,59 @@ import { RadioButton, RadioButtonGroup } from '@affine/component'; +import type { AllPageFilterOption } from '@affine/core/atoms'; +import { allPageFilterSelectAtom } from '@affine/core/atoms'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAtom } from 'jotai'; +import { useCallback, useEffect, useState } from 'react'; -import { allPageModeSelectAtom } from '../../../atoms'; import * as styles from './index.css'; -export const WorkspaceModeFilterTab = () => { +export const WorkspaceModeFilterTab = ({ + workspaceId, + activeFilter, +}: { + workspaceId: string; + activeFilter: AllPageFilterOption; +}) => { const t = useAFFiNEI18N(); - const [value, setMode] = useAtom(allPageModeSelectAtom); - const handleValueChange = (value: string) => { - if (value !== 'all' && value !== 'page' && value !== 'edgeless') { - throw new Error('Invalid value for page mode option'); + const [value, setValue] = useState(activeFilter); + const [filterMode, setFilterMode] = useAtom(allPageFilterSelectAtom); + const { jumpToCollections, jumpToTags, jumpToSubPath } = useNavigateHelper(); + const handleValueChange = useCallback( + (value: AllPageFilterOption) => { + switch (value) { + case 'collections': + jumpToCollections(workspaceId); + break; + case 'tags': + jumpToTags(workspaceId); + break; + case 'docs': + jumpToSubPath(workspaceId, WorkspaceSubPath.ALL); + break; + } + }, + [jumpToCollections, jumpToSubPath, jumpToTags, workspaceId] + ); + + useEffect(() => { + if (value !== activeFilter) { + setValue(activeFilter); + setFilterMode(activeFilter); } - setMode(value); - }; + }, [activeFilter, filterMode, setFilterMode, value]); return ( - - {t['com.affine.pageMode.all']()} + + {t['com.affine.docs.header']()} - - {t['com.affine.pageMode.page']()} + + {t['com.affine.collections.header']()} - - {t['com.affine.pageMode.edgeless']()} + + {t['Tags']()} ); diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 3e888fe866..ffc86afb3c 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -69,15 +69,12 @@ export type RootAppSidebarProps = { const RouteMenuLinkItem = forwardRef< HTMLDivElement, { - currentPath: string; // todo: pass through useRouter? path: string; icon: ReactElement; + active?: boolean; children?: ReactElement; - isDraggedOver?: boolean; } & HTMLAttributes ->(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => { - // Force active style when a page is dragged over - const active = isDraggedOver || currentPath === path; +>(({ path, icon, active, children, ...props }, ref) => { return ( { + if ( + currentPath.startsWith(`/workspace/${currentWorkspaceId}/collection/`) || + currentPath.startsWith(`/workspace/${currentWorkspaceId}/tag/`) + ) { + return true; + } + return currentPath === paths.all(currentWorkspaceId); + }, [currentPath, currentWorkspaceId, paths]); + + const trashActive = useMemo(() => { + return ( + currentPath === paths.trash(currentWorkspaceId) || trashDroppable.isOver + ); + }, [currentPath, currentWorkspaceId, paths, trashDroppable.isOver]); + return ( } - currentPath={currentPath} + active={allPageActive} path={paths.all(currentWorkspaceId)} onClick={backToAll} > @@ -289,9 +302,8 @@ export const RootAppSidebar = ({
} - currentPath={currentPath} + active={trashActive} path={paths.trash(currentWorkspaceId)} > diff --git a/packages/frontend/core/src/hooks/use-navigate-helper.ts b/packages/frontend/core/src/hooks/use-navigate-helper.ts index 5e5dc12dcb..6fbc927674 100644 --- a/packages/frontend/core/src/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/hooks/use-navigate-helper.ts @@ -42,6 +42,22 @@ export function useNavigateHelper() { }, [navigate] ); + const jumpToCollections = useCallback( + (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { + return navigate(`/workspace/${workspaceId}/all?filterMode=collections`, { + replace: logic === RouteLogic.REPLACE, + }); + }, + [navigate] + ); + const jumpToTags = useCallback( + (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { + return navigate(`/workspace/${workspaceId}/all?filterMode=tags`, { + replace: logic === RouteLogic.REPLACE, + }); + }, + [navigate] + ); const jumpToCollection = useCallback( ( workspaceId: string, @@ -144,6 +160,8 @@ export function useNavigateHelper() { jumpToExpired, jumpToSignIn, jumpToCollection, + jumpToCollections, + jumpToTags, }), [ jumpToPage, @@ -156,6 +174,8 @@ export function useNavigateHelper() { jumpToExpired, jumpToSignIn, jumpToCollection, + jumpToCollections, + jumpToTags, ] ); } diff --git a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts index 2a17abc569..6075b1dc4c 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -1,6 +1,6 @@ import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useAtom, useAtomValue, useStore } from 'jotai'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { useTheme } from 'next-themes'; import { useEffect } from 'react'; @@ -26,7 +26,7 @@ export function useRegisterWorkspaceCommands() { const languageHelper = useLanguageHelper(); const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); const navigationHelper = useNavigateHelper(); - const [pageListMode, setPageListMode] = useAtom(allPageModeSelectAtom); + const setPageListMode = useSetAtom(allPageModeSelectAtom); const [editor] = useActiveBlocksuiteEditor(); // register AffineUpdatesCommands @@ -48,7 +48,6 @@ export function useRegisterWorkspaceCommands() { t, workspace: currentWorkspace.blockSuiteWorkspace, navigationHelper, - pageListMode, setPageListMode, }); @@ -60,7 +59,6 @@ export function useRegisterWorkspaceCommands() { t, currentWorkspace.blockSuiteWorkspace, navigationHelper, - pageListMode, setPageListMode, ]); diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx new file mode 100644 index 0000000000..c536cb9f89 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx @@ -0,0 +1,110 @@ +import { IconButton } from '@affine/component'; +import type { AllPageFilterOption } from '@affine/core/atoms'; +import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; +import { + CollectionList, + PageListNewPageButton, + useCollectionManager, +} from '@affine/core/components/page-list'; +import { Header } from '@affine/core/components/pure/header'; +import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; +import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; +import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; +import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info'; +import { PlusIcon } from '@blocksuite/icons'; +import type { Workspace } from '@blocksuite/store'; +import clsx from 'clsx'; +import { useMemo } from 'react'; + +import * as styles from './all-page.css'; +import { FilterContainer } from './all-page-filter'; + +export const AllPageHeader = ({ + workspace, + showCreateNew, + isDefaultFilter, + activeFilter, + onCreateCollection, +}: { + workspace: Workspace; + showCreateNew: boolean; + isDefaultFilter: boolean; + activeFilter: AllPageFilterOption; + onCreateCollection?: () => void; +}) => { + const setting = useCollectionManager(collectionsCRUDAtom); + const config = useAllPageListConfig(); + const userInfo = useDeleteCollectionInfo(); + const isWindowsDesktop = environment.isDesktop && environment.isWindows; + + const disableFilterButton = useMemo(() => { + return activeFilter !== 'docs' && isDefaultFilter; + }, [activeFilter, isDefaultFilter]); + + const renderRightItem = useMemo(() => { + if (activeFilter === 'tags') { + return null; + } + if ( + activeFilter === 'collections' && + isDefaultFilter && + onCreateCollection + ) { + return ( + } + onClick={onCreateCollection} + className={clsx( + styles.headerCreateNewButton, + styles.headerCreateNewCollectionIconButton, + !showCreateNew && styles.headerCreateNewButtonHidden + )} + /> + ); + } + return ( + + + + ); + }, [activeFilter, isDefaultFilter, onCreateCollection, showCreateNew]); + + return ( + <> +
+ } + right={ +
+ {renderRightItem} + {isWindowsDesktop ? : null} +
+ } + center={ + + } + /> + + + ); +}; diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts b/packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts index 70db74ebc4..1619148bd6 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts @@ -14,53 +14,15 @@ export const scrollContainer = style({ paddingBottom: '32px', }); -export const allPagesHeader = style({ - height: 100, - alignItems: 'center', - 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-5)', - 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 headerCreateNewCollectionIconButton = style({ + padding: '4px 8px', + fontSize: '16px', + width: '32px', + height: '28px', + borderRadius: '8px', }); export const headerCreateNewButtonHidden = style({ @@ -72,5 +34,9 @@ export const headerRightWindows = style({ display: 'flex', alignItems: 'center', gap: 8, - transform: 'translateX(16px)', + selectors: { + '&[data-is-windows-desktop="true"]': { + transform: 'translateX(16px)', + }, + }, }); diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx index 5aff78bb27..5642330c48 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx @@ -1,322 +1,209 @@ -import { toast } from '@affine/component'; +import type { AllPageFilterOption } from '@affine/core/atoms'; +import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; +import { HubIsland } from '@affine/core/components/affine/hub-island'; import { - CollectionList, + CollectionListHeader, + type CollectionMeta, + createEmptyCollection, currentCollectionAtom, - FloatingToolbar, - NewPageButton as PureNewPageButton, - OperationCell, - type PageListHandle, + PageListHeader, useCollectionManager, + useEditCollectionName, + useFilteredPageMetas, + useSavedCollections, + useTagMetas, + VirtualizedCollectionList, VirtualizedPageList, } from '@affine/core/components/page-list'; +import { + TagListHeader, + VirtualizedTagList, +} from '@affine/core/components/page-list/tags'; +import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { Trans } from '@affine/i18n'; +import { performanceRenderLogger } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - CloseIcon, - DeleteIcon, - PlusIcon, - ViewLayersIcon, -} from '@blocksuite/icons'; -import type { PageMeta, Workspace } from '@blocksuite/store'; -import clsx from 'clsx'; import { useAtomValue, useSetAtom } from 'jotai'; -import { - type PropsWithChildren, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { nanoid } from 'nanoid'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { NIL } from 'uuid'; -import { collectionsCRUDAtom } from '../../../atoms/collections'; -import { HubIsland } from '../../../components/affine/hub-island'; -import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils'; -import { Header } from '../../../components/pure/header'; -import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls'; -import { WorkspaceModeFilterTab } from '../../../components/pure/workspace-mode-filter-tab'; -import { useAllPageListConfig } from '../../../hooks/affine/use-all-page-list-config'; -import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; -import { useDeleteCollectionInfo } from '../../../hooks/affine/use-delete-collection-info'; -import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper'; -import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import { performanceRenderLogger } from '../../../shared'; -import { EmptyPageList } from '../page-list-empty'; -import { useFilteredPageMetas } from '../pages'; +import { + EmptyCollectionList, + EmptyPageList, + EmptyTagList, +} from '../page-list-empty'; import * as styles from './all-page.css'; -import { FilterContainer } from './all-page-filter'; - -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']()} / -
- -
-
- {setting.currentCollection.name} -
- - ); - }, [setting.currentCollection.name, setting.isDefault, t]); - - return ( -
-
{title}
- - {t['New Page']()} - -
- ); -}; - -const usePageOperationsRenderer = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - 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 ( - - 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, - open, -}: { - open: boolean; - selectedIds: string[]; - onClose: () => void; -}) => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - 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 ( - - - -
- {{ count: selectedIds.length } as any} -
- selected -
-
- } /> - - } - type="danger" - data-testid="page-list-toolbar-delete" - /> -
- ); -}; - -const NewPageButton = ({ - className, - children, - size, - testId, -}: PropsWithChildren<{ - className?: string; - size?: 'small' | 'default'; - testId?: string; -}>) => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - const { importFile, createEdgeless, createPage } = usePageHelper( - currentWorkspace.blockSuiteWorkspace - ); - return ( -
- -
{children}
-
-
- ); -}; - -const AllPageHeader = ({ - workspace, - showCreateNew, -}: { - workspace: Workspace; - showCreateNew: boolean; -}) => { - const setting = useCollectionManager(collectionsCRUDAtom); - const config = useAllPageListConfig(); - const userInfo = useDeleteCollectionInfo(); - const isWindowsDesktop = environment.isDesktop && environment.isWindows; - - return ( - <> -
- } - right={ -
- - - - {isWindowsDesktop ? : null} -
- } - center={} - /> - - - ); -}; +import { AllPageHeader } from './all-page-header'; // even though it is called all page, it is also being used for collection route as well -export const AllPage = () => { +export const AllPage = ({ + activeFilter, +}: { + activeFilter: AllPageFilterOption; +}) => { + const t = useAFFiNEI18N(); + const params = useParams(); const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - const { isPreferredEdgeless } = usePageHelper( - currentWorkspace.blockSuiteWorkspace - ); const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); - const pageOperationsRenderer = usePageOperationsRenderer(); + const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true); + + const setting = useCollectionManager(collectionsCRUDAtom); + const config = useAllPageListConfig(); + const { collections } = useSavedCollections(collectionsCRUDAtom); + const { tags, tagMetas, filterPageMetaByTag, deleteTags } = useTagMetas( + currentWorkspace.blockSuiteWorkspace, + pageMetas + ); const filteredPageMetas = useFilteredPageMetas( 'all', pageMetas, currentWorkspace.blockSuiteWorkspace ); - const [selectedPageIds, setSelectedPageIds] = useState([]); - const pageListRef = useRef(null); + const tagPageMetas = useMemo(() => { + if (params.tagId) { + return filterPageMetaByTag(params.tagId); + } + return []; + }, [filterPageMetaByTag, params.tagId]); - const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + const collectionMetas = useMemo(() => { + const collectionsList: CollectionMeta[] = collections.map(collection => { + return { + ...collection, + title: collection.name, + }; + }); + return collectionsList; + }, [collections]); - const hideFloatingToolbar = useCallback(() => { - pageListRef.current?.toggleSelectable(); - }, []); + const navigateHelper = useNavigateHelper(); + const { open, node } = useEditCollectionName({ + title: t['com.affine.editCollection.createCollection'](), + showTips: true, + }); - // 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 handleCreateCollection = useCallback(() => { + open('') + .then(name => { + const id = nanoid(); + setting.createCollection(createEmptyCollection(id, { name })); + navigateHelper.jumpToCollection(currentWorkspace.id, id); + }) + .catch(err => { + console.error(err); + }); + }, [currentWorkspace.id, navigateHelper, open, setting]); - const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true); + const currentTag = useMemo(() => { + if (params.tagId) { + return tags.find(tag => tag.id === params.tagId); + } + return; + }, [params.tagId, tags]); + + const content = useMemo(() => { + if (filteredPageMetas.length > 0 && activeFilter === 'docs') { + return ( + + ); + } else if (activeFilter === 'collections' && !setting.isDefault) { + return ( + + ); + } else if (activeFilter === 'collections' && setting.isDefault) { + return collectionMetas.length > 0 ? ( + + ) : ( + + } + /> + ); + } else if (activeFilter === 'tags') { + if (params.tagId) { + return tagPageMetas.length > 0 ? ( + + ) : ( + } + blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + /> + ); + } + return tags.length > 0 ? ( + + ) : ( + } /> + ); + } + return ( + } + blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + /> + ); + }, [ + activeFilter, + collectionMetas, + collections, + config, + currentTag, + currentWorkspace.blockSuiteWorkspace, + currentWorkspace.id, + deleteTags, + filteredPageMetas.length, + handleCreateCollection, + node, + params.tagId, + setting.currentCollection, + setting.isDefault, + tagMetas, + tagPageMetas, + tags, + ]); return (
- {filteredPageMetas.length > 0 ? ( - <> - } - selectedPageIds={filteredSelectedPageIds} - onSelectedPageIdsChange={setSelectedPageIds} - pages={filteredPageMetas} - rowAsLink - isPreferredEdgeless={isPreferredEdgeless} - blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} - pageOperationsRenderer={pageOperationsRenderer} - /> - 0} - selectedIds={filteredSelectedPageIds} - onClose={hideFloatingToolbar} - /> - - ) : ( - } - blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} - /> - )} + {content}
); @@ -329,6 +216,18 @@ export const Component = () => { const currentCollection = useSetAtom(currentCollectionAtom); const navigateHelper = useNavigateHelper(); + const location = useLocation(); + const activeFilter = useMemo(() => { + const query = new URLSearchParams(location.search); + const filterMode = query.get('filterMode'); + if (filterMode === 'collections') { + return 'collections'; + } else if (filterMode === 'tags') { + return 'tags'; + } + return 'docs'; + }, [location.search]); + useEffect(() => { function checkJumpOnce() { for (const [pageId] of currentWorkspace.blockSuiteWorkspace.pages) { @@ -355,5 +254,5 @@ export const Component = () => { currentCollection(NIL); }, [currentCollection]); - return ; + return ; }; diff --git a/packages/frontend/core/src/pages/workspace/collection.tsx b/packages/frontend/core/src/pages/workspace/collection.tsx index 446ea36edb..53c478a2db 100644 --- a/packages/frontend/core/src/pages/workspace/collection.tsx +++ b/packages/frontend/core/src/pages/workspace/collection.tsx @@ -10,6 +10,7 @@ import { useEditCollection, } from '@affine/core/components/page-list'; import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; +import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import type { Collection } from '@affine/env/filter'; @@ -31,7 +32,6 @@ import { collectionsCRUDAtom, pageCollectionBaseAtom, } from '../../atoms/collections'; -import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; import { WorkspaceSubPath } from '../../shared'; import { getWorkspaceSetting } from '../../utils/workspace-setting'; @@ -89,17 +89,24 @@ export const Component = function CollectionPage() { return null; } return isEmpty(collection) ? ( - + ) : ( - + ); }; const isWindowsDesktop = environment.isDesktop && environment.isWindows; -const Placeholder = ({ collection }: { collection: Collection }) => { +const Placeholder = ({ + collection, + workspaceId, +}: { + collection: Collection; + workspaceId: string; +}) => { const { updateCollection } = useCollectionManager(collectionsCRUDAtom); const { node, open } = useEditCollection(useAllPageListConfig()); + const { jumpToCollections } = useNavigateHelper(); const openPageEdit = useAsyncCallback(async () => { const ret = await open({ ...collection }, 'page'); updateCollection(ret); @@ -118,6 +125,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => { }, []); const t = useAFFiNEI18N(); const leftSidebarOpen = useAtomValue(appSidebarOpenAtom); + + const handleJumpToCollections = useCallback(() => { + jumpToCollections(workspaceId); + }, [jumpToCollections, workspaceId]); + return (
{ display: 'flex', alignItems: 'center', gap: 4, + cursor: 'pointer', color: 'var(--affine-text-secondary-color)', ['WebkitAppRegion' as string]: 'no-drag', }} + onClick={handleJumpToCollections} > ); }; + +export const EmptyCollectionList = ({ heading }: { heading: ReactNode }) => { + const t = useAFFiNEI18N(); + return ( +
+ {heading &&
{heading}
} + +
+ ); +}; +export const EmptyTagList = ({ heading }: { heading: ReactNode }) => { + const t = useAFFiNEI18N(); + return ( +
+ {heading &&
{heading}
} + +
+ ); +}; diff --git a/packages/frontend/core/src/pages/workspace/tag.tsx b/packages/frontend/core/src/pages/workspace/tag.tsx new file mode 100644 index 0000000000..9cc95d5aa9 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/tag.tsx @@ -0,0 +1,52 @@ +import { TagListHeader, useTagMetas } from '@affine/core/components/page-list'; +import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { useAtomValue } from 'jotai'; +import { useMemo } from 'react'; +import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; + +import { AllPage } from './all-page/all-page'; +import { AllPageHeader } from './all-page/all-page-header'; +import { EmptyPageList } from './page-list-empty'; + +export const loader: LoaderFunction = async args => { + if (!args.params.tagId) { + return redirect('/404'); + } + + return null; +}; + +export const Component = function TagPage() { + const params = useParams(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const { tagUsageCounts } = useTagMetas( + currentWorkspace.blockSuiteWorkspace, + pageMetas + ); + const isEmpty = useMemo(() => { + if (params.tagId) { + return tagUsageCounts[params.tagId] === 0; + } + return true; + }, [params.tagId, tagUsageCounts]); + + return isEmpty ? ( + <> + + } + /> + + ) : ( + + ); +}; diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index d5b4540c53..d7e07a77b7 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -1,9 +1,18 @@ import { toast } from '@affine/component'; +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; import { currentCollectionAtom, + type ListItem, + ListTableHeader, + PageListItemRenderer, TrashOperationCell, - VirtualizedPageList, + useFilteredPageMetas, + VirtualizedList, } from '@affine/core/components/page-list'; +import { pageHeaderColsDef } from '@affine/core/components/page-list/header-col-def'; +import { Header } from '@affine/core/components/pure/header'; +import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; +import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -16,12 +25,7 @@ import { useCallback } from 'react'; import { type LoaderFunction } from 'react-router-dom'; import { NIL } from 'uuid'; -import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; -import { Header } from '../../components/pure/header'; -import { WindowsAppControls } from '../../components/pure/header/windows-app-controls'; -import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper'; import { EmptyPageList } from './page-list-empty'; -import { useFilteredPageMetas } from './pages'; import * as styles from './trash-page.css'; const isWindowsDesktop = environment.isDesktop && environment.isWindows; @@ -74,7 +78,8 @@ export const TrashPage = () => { const t = useAFFiNEI18N(); const pageOperationsRenderer = useCallback( - (page: PageMeta) => { + (item: ListItem) => { + const page = item as PageMeta; const onRestorePage = () => { restoreFromTrash(page.id); toast( @@ -94,20 +99,28 @@ export const TrashPage = () => { /> ); }, + [permanentlyDeletePage, restoreFromTrash, t] ); - + const pageItemRenderer = useCallback((item: ListItem) => { + return ; + }, []); + const pageHeaderRenderer = useCallback(() => { + return ; + }, []); return (
{filteredPageMetas.length > 0 ? ( - ) : ( import('./pages/workspace/collection'), }, + { + path: 'tag/:tagId', + lazy: () => import('./pages/workspace/tag'), + }, { path: 'trash', lazy: () => import('./pages/workspace/trash-page'), diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 1c3884f49c..bc317d99d2 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -554,9 +554,10 @@ "com.affine.collection.menu.edit": "Edit Collection", "com.affine.collection.menu.rename": "Rename", "com.affine.collectionBar.backToAll": "Back to all", + "com.affine.docs.header": "Docs", "com.affine.collections.header": "Collections", "com.affine.collections.empty.message": "No collections", - "com.affine.collections.empty.new-collection-button": "New collection", + "com.affine.collections.empty.new-collection-button": "New Collection", "com.affine.confirmModal.button.cancel": "Cancel", "com.affine.currentYear": "Current Year", "com.affine.deleteLeaveWorkspace.description": "Delete workspace from this device and optionally delete all data.", @@ -601,6 +602,8 @@ "com.affine.editCollectionName.name.placeholder": "Collection Name", "com.affine.editorModeSwitch.tooltip": "Switch", "com.affine.emptyDesc": "There's no page here yet", + "com.affine.emptyDesc.collection": "There's no collection here yet", + "com.affine.emptyDesc.tag": "There's no tag here yet", "com.affine.enableAffineCloudModal.button.cancel": "Cancel", "com.affine.expired.page.subtitle": "Please request a new reset password link.", "com.affine.expired.page.title": "This link has expired...", @@ -714,6 +717,12 @@ "com.affine.page.toolbar.selected": "<0>{{count}} selected", "com.affine.page.toolbar.selected_one": "<0>{{count}} page selected", "com.affine.page.toolbar.selected_others": "<0>{{count}} page(s) selected", + "com.affine.collection.toolbar.selected": "<0>{{count}} selected", + "com.affine.collection.toolbar.selected_one": "<0>{{count}} collection selected", + "com.affine.collection.toolbar.selected_others": "<0>{{count}} collection(s) selected", + "com.affine.tag.toolbar.selected": "<0>{{count}} selected", + "com.affine.tag.toolbar.selected_one": "<0>{{count}} tag selected", + "com.affine.tag.toolbar.selected_others": "<0>{{count}} tag(s) selected", "com.affine.pageMode": "Page Mode", "com.affine.pageMode.all": "all", "com.affine.pageMode.edgeless": "Edgeless", diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index 898336f756..cdc6332ff8 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -212,7 +212,7 @@ test('select two pages and delete', async ({ page }) => { ); // click delete button - await page.locator('[data-testid="page-list-toolbar-delete"]').click(); + await page.locator('[data-testid="list-toolbar-delete"]').click(); // the confirm dialog should appear await expect(page.getByText('Delete 2 pages?')).toBeVisible(); diff --git a/tests/affine-local/e2e/local-first-collections-items.spec.ts b/tests/affine-local/e2e/local-first-collections-items.spec.ts index fe09cbdfe9..cfa091a507 100644 --- a/tests/affine-local/e2e/local-first-collections-items.spec.ts +++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts @@ -18,7 +18,7 @@ const removeOnboardingPages = async (page: Page) => { await page.getByTestId('page-list-header-selection-checkbox').click(); // click again to select all await page.getByTestId('page-list-header-selection-checkbox').click(); - await page.getByTestId('page-list-toolbar-delete').click(); + await page.getByTestId('list-toolbar-delete').click(); // confirm delete await page.getByTestId('confirm-delete-page').click(); }; diff --git a/tests/storybook/src/stories/page-list.stories.tsx b/tests/storybook/src/stories/page-list.stories.tsx index 26c99d6478..f232bcad0b 100644 --- a/tests/storybook/src/stories/page-list.stories.tsx +++ b/tests/storybook/src/stories/page-list.stories.tsx @@ -1,14 +1,15 @@ import { toast } from '@affine/component'; import { FloatingToolbar, + List, + type ListItem, + type ListProps, + ListScrollContainer, NewPageButton, - OperationCell, - type OperationCellProps, - PageList, PageListItem, type PageListItemProps, - type PageListProps, - PageListScrollContainer, + PageOperationCell, + type PageOperationCellProps, PageTags, type PageTagsProps, } from '@affine/core/components/page-list'; @@ -29,9 +30,9 @@ export default { }, } satisfies Meta; -export const AffineOperationCell: StoryFn = ({ +export const AffineOperationCell: StoryFn = ({ ...props -}) => ; +}) => ; AffineOperationCell.args = { favorite: false, @@ -159,11 +160,11 @@ const testTags = [ }, ]; -export const ListItem: StoryFn = props => ( +export const PageListItemComponent: StoryFn = props => ( ); -ListItem.args = { +PageListItemComponent.args = { pageId: 'test-page-id', title: 'Test Page Title', preview: @@ -178,7 +179,7 @@ ListItem.args = { selected: true, }; -ListItem.decorators = [withRouter]; +PageListItemComponent.decorators = [withRouter]; export const ListItemTags: StoryFn = props => (
@@ -195,15 +196,18 @@ ListItemTags.args = { maxItems: 5, }; -export const PageListStory: StoryFn = (props, { loaded }) => { +export const PageListStory: StoryFn> = ( + props, + { loaded } +) => { return ( - - - + + ); };