diff --git a/packages/common/env/src/filter.ts b/packages/common/env/src/filter.ts index 4b41f9a389..2368938a4e 100644 --- a/packages/common/env/src/filter.ts +++ b/packages/common/env/src/filter.ts @@ -14,7 +14,9 @@ export type LiteralValue = | number | string | boolean - | { [K: string]: LiteralValue } + | { + [K: string]: LiteralValue; + } | Array; export const refSchema: z.ZodType = z.object({ @@ -48,15 +50,31 @@ export type Filter = z.input; export const collectionSchema = z.object({ id: z.string(), - workspaceId: z.string(), name: z.string(), - pinned: z.boolean().optional(), + mode: z.union([z.literal('page'), z.literal('rule')]), filterList: z.array(filterSchema), - allowList: z.array(z.string()).optional(), - excludeList: z.array(z.string()).optional(), + allowList: z.array(z.string()), + // page id list + pages: z.array(z.string()), }); - +export const deletedCollectionSchema = z.object({ + userId: z.string().optional(), + userName: z.string(), + collection: collectionSchema, +}); +export type DeprecatedCollection = { + id: string; + name: string; + workspaceId: string; + filterList: z.infer[]; + allowList?: string[]; +}; export type Collection = z.input; +export type DeleteCollectionInfo = { + userId: string; + userName: string; +} | null; +export type DeletedCollection = z.input; export const tagSchema = z.object({ id: z.string(), diff --git a/packages/common/env/src/workspace.ts b/packages/common/env/src/workspace.ts index 65a383b7ec..32ea9b1eca 100644 --- a/packages/common/env/src/workspace.ts +++ b/packages/common/env/src/workspace.ts @@ -8,10 +8,9 @@ import type { import type { PropsWithChildren, ReactNode } from 'react'; import type { DataSourceAdapter } from 'y-provider'; -import type { Collection } from './filter.js'; - export enum WorkspaceSubPath { ALL = 'all', + Collection = 'collection', SETTING = 'setting', TRASH = 'trash', SHARED = 'shared', @@ -137,6 +136,7 @@ type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = { export type WorkspaceHeaderProps = UIBaseProps & { + rightSlot?: ReactNode; currentEntry: | { subPath: WorkspaceSubPath; @@ -167,20 +167,12 @@ type PageDetailProps = onLoadEditor: (page: Page, editor: EditorContainer) => () => void; }; -type PageListProps<_Flavour extends keyof WorkspaceRegistry> = { - blockSuiteWorkspace: BlockSuiteWorkspace; - onOpenPage: (pageId: string, newTab?: boolean) => void; - collection: Collection; -}; - interface FC

{ (props: P): ReactNode; } export interface WorkspaceUISchema { - Header: FC>; PageDetail: FC>; - PageList: FC>; NewSettingsDetail: FC>; Provider: FC; LoginCard?: FC; diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 814579ad53..70cde8a133 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -33,9 +33,11 @@ "@popperjs/core": "^2.11.8", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toolbar": "^1.0.4", "@toeverything/hooks": "workspace:*", "@toeverything/infra": "workspace:*", "@toeverything/theme": "^0.7.20", @@ -47,6 +49,7 @@ "jotai": "^2.4.3", "lit": "^2.8.0", "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "lottie-react": "^2.4.0", "lottie-web": "^5.12.2", "nanoid": "^5.0.1", diff --git a/packages/frontend/component/src/components/app-sidebar/sidebar-containers/index.tsx b/packages/frontend/component/src/components/app-sidebar/sidebar-containers/index.tsx index 60f3a963e6..b67199bb45 100644 --- a/packages/frontend/component/src/components/app-sidebar/sidebar-containers/index.tsx +++ b/packages/frontend/component/src/components/app-sidebar/sidebar-containers/index.tsx @@ -1,6 +1,6 @@ import * as ScrollArea from '@radix-ui/react-scroll-area'; import clsx from 'clsx'; -import { type PropsWithChildren } from 'react'; +import { type PropsWithChildren, useRef } from 'react'; import * as styles from './index.css'; import { useHasScrollTop } from './use-has-scroll-top'; @@ -10,7 +10,8 @@ export function SidebarContainer({ children }: PropsWithChildren) { } export function SidebarScrollableContainer({ children }: PropsWithChildren) { - const [hasScrollTop, ref] = useHasScrollTop(); + const ref = useRef(null); + const hasScrollTop = useHasScrollTop(ref); return (
(null); +export function useHasScrollTop(ref: RefObject | null) { const [hasScrollTop, setHasScrollTop] = useState(false); useEffect(() => { - if (!ref.current) { + if (!ref?.current) { return; } @@ -13,8 +12,10 @@ export function useHasScrollTop() { function updateScrollTop() { if (container) { - const hasScrollTop = container.scrollTop > 0; - setHasScrollTop(hasScrollTop); + setTimeout(() => { + const hasScrollTop = container.scrollTop > 0; + setHasScrollTop(hasScrollTop); + }); } } @@ -23,7 +24,7 @@ export function useHasScrollTop() { return () => { container.removeEventListener('scroll', updateScrollTop); }; - }, []); + }, [ref]); - return [hasScrollTop, ref] as const; + return hasScrollTop; } diff --git a/packages/frontend/component/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts b/packages/frontend/component/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts index d7fea8ef4f..efe0dfbe2c 100644 --- a/packages/frontend/component/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts +++ b/packages/frontend/component/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts @@ -2,13 +2,14 @@ import { style } from '@vanilla-extract/css'; export const sidebarSwitch = style({ opacity: 0, - width: 0, + display: 'none !important', overflow: 'hidden', pointerEvents: 'none', transition: 'all .3s ease-in-out', selectors: { '&[data-show=true]': { opacity: 1, + display: 'inline-flex !important', width: '32px', flexShrink: 0, fontSize: '24px', diff --git a/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index ef21a293cd..65009d631c 100644 --- a/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -6,25 +6,52 @@ import 'fake-indexeddb/auto'; import type { Collection } from '@affine/env/filter'; import { renderHook } from '@testing-library/react'; import { atom } from 'jotai'; +import { atomWithObservable } from 'jotai/utils'; +import { BehaviorSubject } from 'rxjs'; import { expect, test } from 'vitest'; import { createDefaultFilter, vars } from '../filter/vars'; import { - type CollectionsAtom, + type CollectionsCRUDAtom, useCollectionManager, } from '../use-collection-manager'; const defaultMeta = { tags: { options: [] } }; - -const baseAtom = atom([]); - -const mockAtom: CollectionsAtom = atom( - get => get(baseAtom), - async (_, set, update) => { - set(baseAtom, update); +const collectionsSubject = new BehaviorSubject([]); +const baseAtom = atomWithObservable( + () => { + return collectionsSubject; + }, + { + initialValue: [], } ); +const mockAtom: CollectionsCRUDAtom = atom(get => { + return { + collections: get(baseAtom), + addCollection: async (...collections) => { + const prev = collectionsSubject.value; + collectionsSubject.next([...collections, ...prev]); + }, + deleteCollection: async (...ids) => { + const prev = collectionsSubject.value; + collectionsSubject.next(prev.filter(v => !ids.includes(v.id))); + }, + updateCollection: async (id, updater) => { + const prev = collectionsSubject.value; + collectionsSubject.next( + prev.map(v => { + if (v.id === id) { + return updater(v); + } + return v; + }) + ); + }, + }; +}); + test('useAllPageSetting', async () => { const settingHook = renderHook(() => useCollectionManager(mockAtom)); const prevCollection = settingHook.result.current.currentCollection; @@ -32,7 +59,6 @@ test('useAllPageSetting', async () => { await settingHook.result.current.updateCollection({ ...settingHook.result.current.currentCollection, filterList: [createDefaultFilter(vars[0], defaultMeta)], - workspaceId: 'test', }); settingHook.rerender(); const nextCollection = settingHook.result.current.currentCollection; @@ -40,8 +66,7 @@ test('useAllPageSetting', async () => { expect(nextCollection.filterList).toEqual([ createDefaultFilter(vars[0], defaultMeta), ]); - settingHook.result.current.backToAll(); - await settingHook.result.current.saveCollection({ + await settingHook.result.current.createCollection({ ...settingHook.result.current.currentCollection, id: '1', }); diff --git a/packages/frontend/component/src/components/page-list/all-page.tsx b/packages/frontend/component/src/components/page-list/all-page.tsx deleted file mode 100644 index 0910dc2042..0000000000 --- a/packages/frontend/component/src/components/page-list/all-page.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { DEFAULT_SORT_KEY } from '@affine/env/constant'; -import type { PropertiesMeta } from '@affine/env/filter'; -import type { GetPageInfoById } from '@affine/env/page-info'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons'; -import { useMediaQuery, useTheme } from '@mui/material'; -import type React from 'react'; -import { type CSSProperties, useMemo } from 'react'; - -import { - ScrollableContainer, - Table, - TableBody, - TableCell, - TableHead, - TableHeadRow, -} from '../..'; -import { TableBodyRow } from '../../ui/table'; -import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top'; -import { AllPagesBody } from './all-pages-body'; -import { NewPageButton } from './components/new-page-buttton'; -import { TitleCell } from './components/title-cell'; -import { AllPageListMobileView, TrashListMobileView } from './mobile'; -import { TrashOperationCell } from './operation-cell'; -import { StyledTableContainer } from './styles'; -import type { ListData, PageListProps, TrashListData } from './type'; -import type { CollectionsAtom } from './use-collection-manager'; -import { useSorter } from './use-sorter'; -import { formatDate, useIsSmallDevices } from './utils'; -import { CollectionBar } from './view/collection-bar'; - -interface AllPagesHeadProps { - isPublicWorkspace: boolean; - sorter: ReturnType>; - createNewPage: () => void; - createNewEdgeless: () => void; - importFile: () => void; - getPageInfo: GetPageInfoById; - propertiesMeta: PropertiesMeta; - collectionsAtom: CollectionsAtom; -} - -const AllPagesHead = ({ - isPublicWorkspace, - sorter, - createNewPage, - createNewEdgeless, - importFile, - getPageInfo, - propertiesMeta, - collectionsAtom, -}: AllPagesHeadProps) => { - const t = useAFFiNEI18N(); - const titleList = useMemo( - () => [ - { - key: 'title', - content: t['Title'](), - proportion: 0.5, - }, - { - key: 'tags', - content: t['Tags'](), - proportion: 0.2, - }, - { - key: 'createDate', - content: t['Created'](), - proportion: 0.1, - tableCellStyle: { - width: '110px', - } satisfies CSSProperties, - }, - { - key: 'updatedDate', - content: t['Updated'](), - proportion: 0.1, - tableCellStyle: { - width: '110px', - } satisfies CSSProperties, - }, - { - key: 'unsortable_action', - content: ( - - ), - showWhen: () => !isPublicWorkspace, - sortable: false, - tableCellStyle: { - width: '140px', - } satisfies CSSProperties, - styles: { - justifyContent: 'flex-end', - } satisfies CSSProperties, - }, - ], - [createNewEdgeless, createNewPage, importFile, isPublicWorkspace, t] - ); - const tableItem = useMemo( - () => - titleList - .filter(({ showWhen = () => true }) => showWhen()) - .map( - ({ - key, - content, - proportion, - sortable = true, - styles, - tableCellStyle, - }) => ( - sorter.shiftOrder(key as keyof ListData) - : undefined - } - > -
- {content} - {sorter.key === key && - (sorter.order === 'asc' ? ( - - ) : ( - - ))} -
-
- ) - ), - [sorter, titleList] - ); - return ( - - {tableItem} - - - ); -}; - -export const PageList = ({ - isPublicWorkspace = false, - collectionsAtom, - list, - onCreateNewPage, - onCreateNewEdgeless, - onImportFile, - fallback, - getPageInfo, - propertiesMeta, -}: PageListProps) => { - const sorter = useSorter({ - data: list, - key: DEFAULT_SORT_KEY, - order: 'desc', - }); - const [hasScrollTop, ref] = useHasScrollTop(); - const isSmallDevices = useIsSmallDevices(); - if (isSmallDevices) { - return ( - - - - ); - } - - const groupKey = - sorter.key === 'createDate' || sorter.key === 'updatedDate' - ? sorter.key - : // default sort - !sorter.key - ? DEFAULT_SORT_KEY - : undefined; - - return sorter.data.length === 0 && fallback ? ( - {fallback} - ) : ( - - - - - -
-
-
- ); -}; - -const TrashListHead = () => { - const t = useAFFiNEI18N(); - return ( - - - {t['Title']()} - {t['Created']()} - {t['Moved to Trash']()} - - - - ); -}; - -interface PageListTrashViewProps { - list: TrashListData[]; - fallback?: React.ReactNode; -} - -export const PageListTrashView = ({ - list, - fallback, -}: PageListTrashViewProps) => { - const t = useAFFiNEI18N(); - - const theme = useTheme(); - const [hasScrollTop, ref] = useHasScrollTop(); - const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm')); - if (isSmallDevices) { - const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({ - title, - icon, - pageId, - onClickPage, - })); - return ; - } - const ListItems = list.map( - ( - { - pageId, - title, - preview, - icon, - createDate, - trashDate, - onClickPage, - onPermanentlyDeletePage, - onRestorePage, - }, - index - ) => { - return ( - - - {formatDate(createDate)} - - {trashDate ? formatDate(trashDate) : '--'} - - - - - - ); - } - ); - - return list.length === 0 && fallback ? ( - {fallback} - ) : ( - - - - - {ListItems} -
-
-
- ); -}; diff --git a/packages/frontend/component/src/components/page-list/all-pages-body.tsx b/packages/frontend/component/src/components/page-list/all-pages-body.tsx deleted file mode 100644 index 4bad8fe881..0000000000 --- a/packages/frontend/component/src/components/page-list/all-pages-body.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useDraggable } from '@dnd-kit/core'; -import type { ReactNode } from 'react'; -import { Fragment } from 'react'; - -import { styled } from '../../styles'; -import { TableBody, TableCell } from '../../ui/table'; -import { FavoriteTag } from './components/favorite-tag'; -import { Tags } from './components/tags'; -import { TitleCell } from './components/title-cell'; -import { OperationCell } from './operation-cell'; -import { StyledTableBodyRow } from './styles'; -import type { DateKey, DraggableTitleCellData, ListData } from './type'; -import { useDateGroup } from './use-date-group'; -import { formatDate, useIsSmallDevices } from './utils'; - -export const GroupRow = ({ children }: { children: ReactNode }) => { - return ( - - - {children} - - - ); -}; - -export const AllPagesBody = ({ - isPublicWorkspace, - data, - groupKey, -}: { - isPublicWorkspace: boolean; - data: ListData[]; - groupKey?: DateKey; -}) => { - const t = useAFFiNEI18N(); - const isSmallDevices = useIsSmallDevices(); - const dataWithGroup = useDateGroup({ data, key: groupKey }); - return ( - - {dataWithGroup.map( - ( - { - groupName, - pageId, - title, - preview, - tags, - icon, - isPublicPage, - favorite, - createDate, - updatedDate, - onClickPage, - bookmarkPage, - onOpenPageInNewTab, - removeToTrash, - onDisablePublicSharing, - }, - index - ) => { - const displayTitle = title || t['Untitled'](); - return ( - - {groupName && - (index === 0 || - dataWithGroup[index - 1].groupName !== groupName) && ( - {groupName} - )} - - - - - - {!isPublicWorkspace && ( - - - - - )} - - - ); - } - )} - - ); -}; - -const FullSizeButton = styled('button')(() => ({ - width: '100%', - height: '100%', - display: 'block', -})); - -type DraggableTitleCellProps = { - pageId: string; - draggableData?: DraggableTitleCellData; -} & React.ComponentProps; - -function DraggableTitleCell({ - pageId, - draggableData, - ...props -}: DraggableTitleCellProps) { - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: 'page-list-item-title-' + pageId, - data: draggableData, - }); - - return ( - - {/* Use `button` for draggable element */} - {/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */} - {element => ( - - {element} - - )} - - ); -} diff --git a/packages/frontend/component/src/components/page-list/components/dropdown.css.ts b/packages/frontend/component/src/components/page-list/components/dropdown.css.ts deleted file mode 100644 index 6dec4272d7..0000000000 --- a/packages/frontend/component/src/components/page-list/components/dropdown.css.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const divider = style({ - width: '0.5px', - height: '16px', - background: 'var(--affine-divider-color)', - // fix dropdown button click area - margin: '0 4px', - marginRight: 0, -}); - -export const dropdownWrapper = style({ - width: '100%', - height: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - paddingLeft: '4px', - paddingRight: '10px', -}); - -export const dropdownIcon = style({ - borderRadius: '4px', - selectors: { - [`${dropdownWrapper}:hover &`]: { - background: 'var(--affine-hover-color)', - }, - }, -}); - -export const dropdownBtn = style({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - padding: '0 10px', - // fix dropdown button click area - paddingRight: 0, - color: 'var(--affine-text-primary-color)', - fontWeight: 600, - background: 'var(--affine-button-gray-color)', - boxShadow: 'var(--affine-float-button-shadow)', - borderRadius: '8px', - fontSize: 'var(--affine-font-sm)', - // width: '100%', - height: '32px', - userSelect: 'none', - whiteSpace: 'nowrap', - cursor: 'pointer', - selectors: { - '&:hover': { - background: 'var(--affine-hover-color-filled)', - }, - }, -}); - -export const menuContent = style({ - backgroundColor: 'var(--affine-background-overlay-panel-color)', -}); diff --git a/packages/frontend/component/src/components/page-list/components/dropdown.tsx b/packages/frontend/component/src/components/page-list/components/dropdown.tsx deleted file mode 100644 index a2970eaf83..0000000000 --- a/packages/frontend/component/src/components/page-list/components/dropdown.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { ArrowDownSmallIcon } from '@blocksuite/icons'; -import { - type ButtonHTMLAttributes, - forwardRef, - type MouseEventHandler, -} from 'react'; - -import * as styles from './dropdown.css'; - -type DropdownButtonProps = { - onClickDropDown?: MouseEventHandler; -} & ButtonHTMLAttributes; - -export const DropdownButton = forwardRef< - HTMLButtonElement, - DropdownButtonProps ->(({ onClickDropDown, children, ...props }, ref) => { - const handleClickDropDown: MouseEventHandler = e => { - e.stopPropagation(); - onClickDropDown?.(e); - }; - return ( - - ); -}); -DropdownButton.displayName = 'DropdownButton'; diff --git a/packages/frontend/component/src/components/page-list/components/favorite-tag.tsx b/packages/frontend/component/src/components/page-list/components/favorite-tag.tsx index cfb4258314..7256ff2e1e 100644 --- a/packages/frontend/component/src/components/page-list/components/favorite-tag.tsx +++ b/packages/frontend/component/src/components/page-list/components/favorite-tag.tsx @@ -21,6 +21,7 @@ export const FavoriteTag = forwardRef< const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); + e.preventDefault(); onClick?.(e); setPlayAnimation(!active); }, diff --git a/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx b/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx new file mode 100644 index 0000000000..5c3efe0014 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx @@ -0,0 +1,136 @@ +import * as Popover from '@radix-ui/react-popover'; +import * as Toolbar from '@radix-ui/react-toolbar'; +import clsx from 'clsx'; +import { + type CSSProperties, + type MouseEventHandler, + type PropsWithChildren, + type ReactNode, + useEffect, + useRef, +} from 'react'; + +import * as styles from './floating-toolbar.css'; + +interface FloatingToolbarProps { + className?: string; + style?: CSSProperties; + open?: boolean; + // if dbclick outside of the panel, close the toolbar + onOpenChange?: (open: boolean) => void; +} + +interface FloatingToolbarButtonProps { + icon: ReactNode; + onClick: MouseEventHandler; + type?: 'danger' | 'default'; + label?: ReactNode; + className?: string; + style?: CSSProperties; +} + +interface FloatingToolbarItemProps {} + +export function FloatingToolbar({ + children, + style, + className, + open, + onOpenChange, +}: PropsWithChildren) { + const contentRef = useRef(null); + const animatingRef = useRef(false); + + // todo: move dbclick / esc to close to page list instead + useEffect(() => { + animatingRef.current = true; + const timer = setTimeout(() => { + animatingRef.current = false; + }, 200); + + if (open) { + // when dbclick outside of the panel or typing ESC, close the toolbar + const dbcHandler = (e: MouseEvent) => { + if ( + !contentRef.current?.contains(e.target as Node) && + !animatingRef.current + ) { + // close the toolbar + onOpenChange?.(false); + } + }; + + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !animatingRef.current) { + onOpenChange?.(false); + } + }; + + document.addEventListener('dblclick', dbcHandler); + document.addEventListener('keydown', escHandler); + return () => { + clearTimeout(timer); + document.removeEventListener('dblclick', dbcHandler); + document.removeEventListener('keydown', escHandler); + }; + } + return () => { + clearTimeout(timer); + }; + }, [onOpenChange, open]); + + return ( + + {/* Having Anchor here to let Popover to calculate the position of the place it is being used */} + + + {/* always pop up on top for now */} + + + {children} + + + + + ); +} + +// freestyle item that allows user to do anything +export function FloatingToolbarItem({ + children, +}: PropsWithChildren) { + return
{children}
; +} + +// a typical button that has icon and label +export function FloatingToolbarButton({ + icon, + type, + onClick, + className, + style, + label, +}: FloatingToolbarButtonProps) { + return ( + +
{icon}
+ {label} +
+ ); +} + +export function FloatingToolbarSeparator() { + return ; +} + +FloatingToolbar.Item = FloatingToolbarItem; +FloatingToolbar.Separator = FloatingToolbarSeparator; +FloatingToolbar.Button = FloatingToolbarButton; diff --git a/packages/frontend/component/src/components/page-list/components/floating-toolbar.css.ts b/packages/frontend/component/src/components/page-list/components/floating-toolbar.css.ts new file mode 100644 index 0000000000..45cb37b633 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/components/floating-toolbar.css.ts @@ -0,0 +1,93 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +const slideDownAndFade = keyframes({ + '0%': { + opacity: 0, + transform: 'scale(0.95) translateY(20px)', + }, + '100%': { + opacity: 1, + transform: 'scale(1) translateY(0)', + }, +}); + +const slideUpAndFade = keyframes({ + '0%': { + opacity: 1, + transform: 'scale(1) translateY(0)', + }, + '100%': { + opacity: 0, + transform: 'scale(0.95) translateY(20px)', + }, +}); + +export const root = style({ + display: 'flex', + alignItems: 'center', + borderRadius: '10px', + padding: '4px', + border: '1px solid var(--affine-border-color)', + boxShadow: 'var(--affine-menu-shadow)', + gap: 4, + minWidth: 'max-content', + width: 'fit-content', + background: 'var(--affine-background-primary-color)', +}); + +export const popoverContent = style({ + willChange: 'transform opacity', + selectors: { + '&[data-state="open"]': { + animation: `${slideDownAndFade} 0.2s ease-in-out`, + }, + '&[data-state="closed"]': { + animation: `${slideUpAndFade} 0.2s ease-in-out`, + }, + }, +}); + +export const separator = style({ + width: '1px', + height: '24px', + background: 'var(--affine-divider-color)', +}); + +export const item = style({ + display: 'flex', + alignItems: 'center', + color: 'inherit', + gap: 4, + height: '32px', + padding: '0 6px', +}); + +export const button = style([ + item, + { + borderRadius: '8px', + ':hover': { + background: 'var(--affine-hover-color)', + }, + }, +]); + +export const danger = style({ + color: 'inherit', + ':hover': { + background: 'var(--affine-background-error-color)', + color: 'var(--affine-error-color)', + }, +}); + +export const buttonIcon = style({ + display: 'flex', + alignItems: 'center', + fontSize: 20, + color: 'var(--affine-icon-color)', + selectors: { + [`${danger}:hover &`]: { + color: 'var(--affine-error-color)', + }, + }, +}); diff --git a/packages/frontend/component/src/components/page-list/components/new-page-button.css.ts b/packages/frontend/component/src/components/page-list/components/new-page-button.css.ts new file mode 100644 index 0000000000..9565e7e2a5 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/components/new-page-button.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css'; + +export const menuContent = style({ + backgroundColor: 'var(--affine-background-overlay-panel-color)', +}); diff --git a/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx b/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx index 811d94453d..613277af2e 100644 --- a/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx +++ b/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx @@ -1,16 +1,17 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons'; import { Menu } from '@toeverything/components/menu'; -import { useCallback, useState } from 'react'; +import { type PropsWithChildren, useCallback, useState } from 'react'; +import { DropdownButton } from '../../../ui/button'; import { BlockCard } from '../../card/block-card'; -import { DropdownButton } from './dropdown'; -import { menuContent } from './dropdown.css'; +import { menuContent } from './new-page-button.css'; type NewPageButtonProps = { createNewPage: () => void; createNewEdgeless: () => void; importFile: () => void; + size?: 'small' | 'default'; }; export const CreateNewPagePopup = ({ @@ -58,8 +59,9 @@ export const NewPageButton = ({ createNewPage, createNewEdgeless, importFile, -}: NewPageButtonProps) => { - const t = useAFFiNEI18N(); + size, + children, +}: PropsWithChildren) => { const [open, setOpen] = useState(false); return ( { createNewPage(); setOpen(false); }, [createNewPage])} onClickDropDown={useCallback(() => setOpen(open => !open), [])} > - {t['New Page']()} + {children} ); diff --git a/packages/frontend/component/src/components/page-list/components/tags.css.ts b/packages/frontend/component/src/components/page-list/components/tags.css.ts deleted file mode 100644 index 5ee8446f5c..0000000000 --- a/packages/frontend/component/src/components/page-list/components/tags.css.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const tagList = style({ - display: 'flex', - flexWrap: 'nowrap', - gap: 10, - overflow: 'hidden', -}); -export const tagListFull = style({ - display: 'flex', - flexWrap: 'wrap', - gap: 10, - maxWidth: 300, - padding: 10, - overflow: 'hidden', -}); - -export const tag = style({ - flexShrink: 0, - padding: '2px 10px', - borderRadius: 6, - fontSize: 12, - lineHeight: '16px', - fontWeight: 400, - maxWidth: '100%', - color: 'var(--affine-text-primary-color)', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}); diff --git a/packages/frontend/component/src/components/page-list/components/tags.tsx b/packages/frontend/component/src/components/page-list/components/tags.tsx deleted file mode 100644 index 273a313f21..0000000000 --- a/packages/frontend/component/src/components/page-list/components/tags.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Tag } from '@affine/env/filter'; -import { Menu } from '@toeverything/components/menu'; - -import * as styles from './tags.css'; - -// fixme: This component should use popover instead of menu -export const Tags = ({ value }: { value: Tag[] }) => { - const list = value.map(tag => { - return ( -
- {tag.value} -
- ); - }); - return ( - {list}
}> -
{list}
- - ); -}; diff --git a/packages/frontend/component/src/components/page-list/components/title-cell.tsx b/packages/frontend/component/src/components/page-list/components/title-cell.tsx deleted file mode 100644 index 7786d2cfef..0000000000 --- a/packages/frontend/component/src/components/page-list/components/title-cell.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useCallback } from 'react'; - -import type { TableCellProps } from '../../..'; -import { Content, TableCell } from '../../..'; -import { - StyledTitleContentWrapper, - StyledTitleLink, - StyledTitlePreview, -} from '../styles'; - -type TitleCellProps = { - icon: JSX.Element; - text: string; - desc?: React.ReactNode; - suffix?: JSX.Element; - /** - * Customize the children of the cell - * @param element - * @returns - */ - children?: (element: React.ReactElement) => React.ReactNode; -} & Omit; - -export const TitleCell = React.forwardRef( - ({ icon, text, desc, suffix, children: render, ...props }, ref) => { - const renderChildren = useCallback(() => { - const childElement = ( - <> - - {icon} - - - {text} - - {desc && ( - - {desc} - - )} - - - {suffix} - - ); - - return render ? render(childElement) : childElement; - }, [desc, icon, render, suffix, text]); - - return ( - - {renderChildren()} - - ); - } -); -TitleCell.displayName = 'TitleCell'; diff --git a/packages/frontend/component/src/components/page-list/filter/filter-list.tsx b/packages/frontend/component/src/components/page-list/filter/filter-list.tsx index ef2c641023..3169814ca7 100644 --- a/packages/frontend/component/src/components/page-list/filter/filter-list.tsx +++ b/packages/frontend/component/src/components/page-list/filter/filter-list.tsx @@ -63,7 +63,7 @@ export const FilterList = ({ > {value.length === 0 ? ( + ) : null} + + ) : null} + +
+ {items.map(item => ( + + ))} +
+
+ + ); +}; + +// todo: optimize how to render page meta list item +const requiredPropNames = [ + 'blockSuiteWorkspace', + 'clickMode', + 'isPreferredEdgeless', + 'pageOperationsRenderer', + 'selectedPageIds', + 'onSelectedPageIdsChange', + 'draggable', +] as const; + +type RequiredProps = Pick & { + selectable: boolean; +}; + +const listPropsAtom = selectAtom( + pageListPropsAtom, + props => { + return Object.fromEntries( + requiredPropNames.map(name => [name, props[name]]) + ) as RequiredProps; + }, + isEqual +); + +const PageMetaListItemRenderer = (pageMeta: PageMeta) => { + const props = useAtomValue(listPropsAtom); + const { selectionActive } = useAtomValue(selectionStateAtom); + return ( + + ); +}; + +function tagIdToTagOption( + tagId: string, + blockSuiteWorkspace: Workspace +): Tag | undefined { + return blockSuiteWorkspace.meta.properties.tags?.options.find( + opt => opt.id === tagId + ); +} + +function pageMetaToPageItemProp( + pageMeta: PageMeta, + props: RequiredProps +): PageListItemProps { + const toggleSelection = props.onSelectedPageIdsChange + ? () => { + assertExists(props.selectedPageIds); + const prevSelected = props.selectedPageIds.includes(pageMeta.id); + const shouldAdd = !prevSelected; + const shouldRemove = prevSelected; + + if (shouldAdd) { + props.onSelectedPageIdsChange?.([ + ...props.selectedPageIds, + pageMeta.id, + ]); + } else if (shouldRemove) { + props.onSelectedPageIdsChange?.( + props.selectedPageIds.filter(id => id !== pageMeta.id) + ); + } + } + : undefined; + const itemProps: PageListItemProps = { + pageId: pageMeta.id, + title: pageMeta.title, + preview: ( + + ), + createDate: new Date(pageMeta.createDate), + updatedDate: pageMeta.updatedDate + ? new Date(pageMeta.updatedDate) + : undefined, + to: + props.clickMode === 'link' + ? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}` + : undefined, + onClick: props.clickMode === 'select' ? toggleSelection : undefined, + icon: props.isPreferredEdgeless?.(pageMeta.id) ? ( + + ) : ( + + ), + tags: + pageMeta.tags + ?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace)) + .filter((v): v is Tag => v != null) ?? [], + operations: props.pageOperationsRenderer?.(pageMeta), + selectable: props.selectable, + selected: props.selectedPageIds?.includes(pageMeta.id), + onSelectedChange: toggleSelection, + draggable: props.draggable, + isPublicPage: !!pageMeta.isPublic, + }; + return itemProps; +} diff --git a/packages/frontend/component/src/components/page-list/page-list-item.css.ts b/packages/frontend/component/src/components/page-list/page-list-item.css.ts new file mode 100644 index 0000000000..2fee2391d4 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-list-item.css.ts @@ -0,0 +1,175 @@ +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', + height: '54px', // 42 + 12 + alignItems: 'center', + background: 'var(--affine-hover-color-filled)', + boxShadow: 'var(--affine-menu-shadow)', + borderRadius: 10, + zIndex: 1001, + cursor: 'pointer', + maxWidth: '360px', + transition: 'transform 0.2s', + willChange: 'transform', + selectors: { + '&[data-over=true]': { + transform: 'scale(0.8)', + }, + }, +}); + +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/component/src/components/page-list/page-list-item.tsx b/packages/frontend/component/src/components/page-list/page-list-item.tsx new file mode 100644 index 0000000000..3e69482234 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-list-item.tsx @@ -0,0 +1,254 @@ +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 { Checkbox } from '../../ui/checkbox'; +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 = ({ + title, + preview, +}: Pick) => { + const t = useAFFiNEI18N(); + return ( +
+
+ {title || t['Untitled']()} +
+ {preview ? ( +
+ {preview} +
+ ) : null} +
+ ); +}; + +const PageListIconCell = ({ icon }: Pick) => { + return ( +
+ {icon} +
+ ); +}; + +const PageSelectionCell = ({ + selectable, + onSelectedChange, + selected, +}: Pick) => { + const onSelectionChange = useCallback( + (_event: React.ChangeEvent) => { + return onSelectedChange?.(); + }, + [onSelectedChange] + ); + if (!selectable) { + return null; + } + return ( +
+ +
+ ); +}; + +export const PageTagsCell = ({ tags }: Pick) => { + return ( +
+ +
+ ); +}; + +const PageCreateDateCell = ({ + createDate, +}: Pick) => { + return ( +
+ {formatDate(createDate)} +
+ ); +}; + +const PageUpdatedDateCell = ({ + updatedDate, +}: Pick) => { + return ( +
+ {updatedDate ? formatDate(updatedDate) : '-'} +
+ ); +}; + +const PageListOperationsCell = ({ + operations, +}: Pick) => { + return operations ? ( +
+ {operations} +
+ ) : null; +}; + +export const PageListItem = (props: PageListItemProps) => { + const pageTitleElement = useMemo(() => { + return ( + <> +
+ + +
+ + + ); + }, [ + props.icon, + props.onSelectedChange, + props.preview, + props.selectable, + props.selected, + props.title, + ]); + + const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + id: 'page-list-item-title-' + props.pageId, + data: { + pageId: props.pageId, + pageTitle: pageTitleElement, + } satisfies DraggableTitleCellData, + disabled: !props.draggable, + }); + + return ( + + + +
+ + +
+ +
+ + + +
+ + + + + + + {props.operations ? ( + + + + ) : null} +
+ ); +}; + +type PageListWrapperProps = PropsWithChildren< + Pick & { + isDragging: boolean; + } +>; + +function PageListItemWrapper({ + to, + isDragging, + pageId, + onClick, + children, + draggable, +}: PageListWrapperProps) { + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (onClick) { + stopPropagation(e); + onClick(); + } + }, + [onClick] + ); + + const commonProps = useMemo( + () => ({ + 'data-testid': 'page-list-item', + 'data-page-id': pageId, + 'data-draggable': draggable, + className: styles.root, + 'data-clickable': !!onClick || !!to, + 'data-dragging': isDragging, + onClick: handleClick, + }), + [pageId, draggable, isDragging, onClick, to, handleClick] + ); + + if (to) { + return ( + + {children} + + ); + } else { + return
{children}
; + } +} + +export const PageListDragOverlay = ({ + children, + over, +}: PropsWithChildren<{ + over?: boolean; +}>) => { + return ( +
+ {children} +
+ ); +}; diff --git a/packages/frontend/component/src/components/page-list/page-list.css.ts b/packages/frontend/component/src/components/page-list/page-list.css.ts new file mode 100644 index 0000000000..e20ca1b569 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-list.css.ts @@ -0,0 +1,111 @@ +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({ + overflowY: 'auto', + width: '100%', +}); + +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 header = 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] ${header}`, { + 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: 'center', + fontSize: '16px', +}); + +export const headerCellSortIcon = style({ + width: '14px', + height: '14px', +}); + +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, + }, + }, +}); diff --git a/packages/frontend/component/src/components/page-list/page-list.tsx b/packages/frontend/component/src/components/page-list/page-list.tsx new file mode 100644 index 0000000000..43492997c6 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-list.tsx @@ -0,0 +1,301 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import clsx from 'clsx'; +import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { + type ForwardedRef, + forwardRef, + type MouseEventHandler, + type PropsWithChildren, + type ReactNode, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; + +import { Checkbox, type CheckboxProps } from '../../ui/checkbox'; +import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top'; +import { PageGroup } from './page-group'; +import * as styles from './page-list.css'; +import { + pageGroupsAtom, + pageListHandlersAtom, + pageListPropsAtom, + pagesAtom, + selectionStateAtom, + showOperationsAtom, + sorterAtom, +} from './scoped-atoms'; +import type { PageListHandle, PageListProps } from './types'; +import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils'; + +/** + * Given a list of pages, render a list of pages + */ +export const PageList = forwardRef( + function PageListHandle(props, ref) { + return ( + + + + ); + } +); + +const PageListInner = ({ + handleRef, + ...props +}: PageListProps & { handleRef: ForwardedRef }) => { + // push pageListProps to the atom so that downstream components can consume it + useHydrateAtoms([[pageListPropsAtom, props]], { + // note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom + // note2: not using it for now because it will cause some other issues + // dangerouslyForceHydrate: true, + }); + + const setPageListPropsAtom = useSetAtom(pageListPropsAtom); + const setPageListSelectionState = useSetAtom(selectionStateAtom); + + useEffect(() => { + setPageListPropsAtom(props); + }, [props, setPageListPropsAtom]); + + useImperativeHandle( + handleRef, + () => { + return { + toggleSelectable: () => { + setPageListSelectionState(false); + }, + }; + }, + [setPageListSelectionState] + ); + + const groups = useAtomValue(pageGroupsAtom); + const hideHeader = props.hideHeader; + return ( +
+ {!hideHeader ? : null} +
+ {groups.map(group => ( + + ))} +
+
+ ); +}; + +type HeaderCellProps = ColWrapperProps & { + sortKey: keyof PageMeta; + sortable?: boolean; +}; + +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; +}; + +// 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 [selectionState, setSelectionState] = useAtom(selectionStateAtom); + const pages = useAtomValue(pagesAtom); + const onActivateSelection: MouseEventHandler = useCallback( + e => { + stopPropagation(e); + setSelectionState(true); + }, + [setSelectionState] + ); + const handlers = useAtomValue(pageListHandlersAtom); + const onChange: NonNullable = useCallback( + (e, checked) => { + stopPropagation(e); + handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []); + }, + [handlers, pages] + ); + + if (!selectionState.selectable) { + return null; + } + + return ( +
+ {!selectionState.selectionActive ? ( + + ) : ( + 0 && + selectionState.selectedPageIds.length < pages.length + } + onChange={onChange} + /> + )} +
+ ); +}; + +const PageListHeaderTitleCell = () => { + const t = useAFFiNEI18N(); + return ( +
+ + {t['Title']()} +
+ ); +}; + +export const PageListHeader = () => { + const t = useAFFiNEI18N(); + const showOperations = useAtomValue(showOperationsAtom); + 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]); + return ( +
+ {headerCols.map(col => { + return ( + + {col.content} + + ); + })} +
+ ); +}; + +interface PageListScrollContainerProps { + className?: string; + style?: React.CSSProperties; +} + +export const PageListScrollContainer = forwardRef< + HTMLDivElement, + PropsWithChildren +>(({ className, children, style }, ref) => { + const containerRef = useRef(null); + const hasScrollTop = useHasScrollTop(containerRef); + + const setNodeRef = useCallback( + (r: HTMLDivElement) => { + if (ref) { + if (typeof ref === 'function') { + ref(r); + } else { + ref.current = r; + } + } + containerRef.current = r; + }, + [ref] + ); + + return ( +
+ {children} +
+ ); +}); + +PageListScrollContainer.displayName = 'PageListScrollContainer'; diff --git a/packages/frontend/component/src/components/page-list/page-tags.css.ts b/packages/frontend/component/src/components/page-list/page-tags.css.ts new file mode 100644 index 0000000000..8211c9bbe2 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-tags.css.ts @@ -0,0 +1,138 @@ +import { style } from '@vanilla-extract/css'; + +export const root = style({ + position: 'relative', + width: '100%', + height: '100%', + minHeight: '32px', +}); + +export const tagsContainer = style({ + display: 'flex', + alignItems: 'center', +}); + +export const tagsScrollContainer = style([ + tagsContainer, + { + overflow: 'auto', + height: '100%', + gap: '8px', + }, +]); + +export const tagsListContainer = style([ + tagsContainer, + { + flexWrap: 'wrap', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '4px', + }, +]); + +export const innerContainer = style({ + display: 'flex', + columnGap: '8px', + alignItems: 'center', + position: 'absolute', + height: '100%', + maxWidth: '100%', + transition: 'all 0.2s 0.3s ease-in-out', + selectors: { + [`${root}:hover &`]: { + maxWidth: 'var(--hover-max-width)', + }, + }, +}); + +// background with linear gradient hack +export const innerBackdrop = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '100%', + opacity: 0, + transition: 'all 0.2s', + background: + 'linear-gradient(90deg, transparent 0%, var(--affine-hover-color-filled) 40%)', + selectors: { + [`${root}:hover &`]: { + opacity: 1, + }, + }, +}); + +const range = (start: number, end: number) => { + const result = []; + for (let i = start; i < end; i++) { + result.push(i); + } + return result; +}; + +export const tag = style({ + height: '20px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 8px', + color: 'var(--affine-text-primary-color)', +}); + +export const tagSticky = style([ + tag, + { + fontSize: 'var(--affine-font-xs)', + borderRadius: '10px', + columnGap: '4px', + border: '1px solid var(--affine-border-color)', + background: 'var(--affine-background-primary-color)', + maxWidth: '128px', + position: 'sticky', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + left: 0, + selectors: range(0, 20).reduce((selectors, i) => { + return { + ...selectors, + [`&:nth-last-child(${i + 1})`]: { + right: `${i * 48}px`, + }, + }; + }, {}), + }, +]); + +export const tagListItem = style([ + tag, + { + fontSize: 'var(--affine-font-sm)', + padding: '4px 12px', + columnGap: '8px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + height: '30px', + }, +]); + +export const showMoreTag = style({ + fontSize: 'var(--affine-font-h-5)', + right: 0, + position: 'sticky', + display: 'inline-flex', +}); + +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/component/src/components/page-list/page-tags.tsx b/packages/frontend/component/src/components/page-list/page-tags.tsx new file mode 100644 index 0000000000..edf9e91505 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-tags.tsx @@ -0,0 +1,135 @@ +import type { Tag } from '@affine/env/filter'; +import { MoreHorizontalIcon } from '@blocksuite/icons'; +import { Menu } from '@toeverything/components/menu'; +import clsx from 'clsx'; +import { useEffect, useMemo, useRef } from 'react'; + +import * as styles from './page-tags.css'; +import { stopPropagation } from './utils'; + +export interface PageTagsProps { + tags: Tag[]; + maxItems?: number; // max number to show. if not specified, show all. if specified, show the first n items and add a "..." tag + widthOnHover?: number | string; // max width on hover + hoverExpandDirection?: 'left' | 'right'; // expansion direction on hover +} + +interface TagItemProps { + tag: Tag; + idx: number; + mode: 'sticky' | 'list-item'; +} + +// 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)', + }; + return mapping[color] || color; +}; + +const TagItem = ({ tag, idx, mode }: TagItemProps) => { + return ( +
+
+
{tag.value}
+
+ ); +}; + +export const PageTags = ({ + tags, + widthOnHover, + maxItems, + hoverExpandDirection, +}: PageTagsProps) => { + const sanitizedWidthOnHover = widthOnHover + ? typeof widthOnHover === 'string' + ? widthOnHover + : `${widthOnHover}px` + : 'auto'; + const tagsContainerRef = useRef(null); + + useEffect(() => { + if (tagsContainerRef.current) { + const tagsContainer = tagsContainerRef.current; + const listener = () => { + // on mouseleave, reset scroll position to the hoverExpandDirection + tagsContainer.scrollTo({ + left: hoverExpandDirection === 'left' ? Number.MAX_SAFE_INTEGER : 0, + behavior: 'smooth', + }); + }; + listener(); + tagsContainerRef.current.addEventListener('mouseleave', listener); + return () => { + tagsContainer.removeEventListener('mouseleave', listener); + }; + } + return; + }, [hoverExpandDirection]); + + const tagsInPopover = useMemo(() => { + const lastTags = tags.slice(maxItems); + return ( +
+ {lastTags.map((tag, idx) => ( + + ))} +
+ ); + }, [maxItems, tags]); + + const tagsNormal = useMemo(() => { + const nTags = maxItems ? tags.slice(0, maxItems) : tags; + return nTags.map((tag, idx) => ( + + )); + }, [maxItems, tags]); + return ( +
+
+
+
+ {tagsNormal} +
+ {maxItems && tags.length > maxItems ? ( + +
+ +
+
+ ) : null} +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/page-list/readme.md b/packages/frontend/component/src/components/page-list/readme.md new file mode 100644 index 0000000000..687b1fd062 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/readme.md @@ -0,0 +1,4 @@ +# + +A new implementation of the list table component for Page. Replace existing `PageList` component. +May rename to `PageList` later. diff --git a/packages/frontend/component/src/components/page-list/scoped-atoms.ts b/packages/frontend/component/src/components/page-list/scoped-atoms.ts new file mode 100644 index 0000000000..3445df4d8e --- /dev/null +++ b/packages/frontend/component/src/components/page-list/scoped-atoms.ts @@ -0,0 +1,188 @@ +import { DEFAULT_SORT_KEY } from '@affine/env/constant'; +import type { PageMeta } from '@blocksuite/store'; +import { atom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { isEqual } from 'lodash-es'; + +import { pagesToPageGroups } from './page-group'; +import type { PageListProps, PageMetaRecord } from './types'; + +// 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(); + +// whether or not the table is in selection mode (showing selection checkbox & selection floating bar) +const selectionActiveAtom = atom(false); + +export const selectionStateAtom = atom( + get => { + const baseAtom = selectAtom( + pageListPropsAtom, + props => { + const { selectable, selectedPageIds, onSelectedPageIdsChange } = props; + return { + selectable, + selectedPageIds, + onSelectedPageIdsChange, + }; + }, + isEqual + ); + const baseState = get(baseAtom); + const selectionActive = + baseState.selectable === 'toggle' + ? get(selectionActiveAtom) + : baseState.selectable; + return { + ...baseState, + selectionActive, + }; + }, + (_get, set, active: boolean) => { + set(selectionActiveAtom, active); + } +); + +// get handlers from pageListPropsAtom +export const pageListHandlersAtom = selectAtom( + pageListPropsAtom, + props => { + const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props; + + return { + onSelectedPageIdsChange, + onDragStart, + onDragEnd, + }; + }, + isEqual +); + +export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages); + +export const showOperationsAtom = selectAtom( + pageListPropsAtom, + props => !!props.pageOperationsRenderer +); + +type SortingContext = { + key: T; + order: 'asc' | 'desc'; + fallbackKey?: T; +}; + +type SorterConfig = Record> = + { + key?: keyof T; + order: 'asc' | 'desc'; + sortingFn: (ctx: SortingContext, a: T, b: T) => number; + }; + +const defaultSortingFn: SorterConfig['sortingFn'] = ( + ctx, + a, + b +) => { + const val = (obj: PageMetaRecord) => { + let v = obj[ctx.key]; + if (v === undefined && ctx.fallbackKey) { + v = obj[ctx.fallbackKey]; + } + return v; + }; + const valA = val(a); + const valB = val(b); + const revert = ctx.order === 'desc'; + const revertSymbol = revert ? -1 : 1; + if (typeof valA === 'string' && typeof valB === 'string') { + return valA.localeCompare(valB) * revertSymbol; + } + if (typeof valA === 'number' && typeof valB === 'number') { + return (valA - valB) * revertSymbol; + } + if (valA instanceof Date && valB instanceof Date) { + return (valA.getTime() - valB.getTime()) * revertSymbol; + } + if (!valA) { + return -1 * revertSymbol; + } + if (!valB) { + return 1 * revertSymbol; + } + + if (Array.isArray(valA) && Array.isArray(valB)) { + return (valA.length - valB.length) * revertSymbol; + } + console.warn( + 'Unsupported sorting type! Please use custom sorting function.', + valA, + valB + ); + return 0; +}; + +const sorterStateAtom = atom>({ + key: DEFAULT_SORT_KEY, + order: 'desc', + sortingFn: defaultSortingFn, +}); + +export const sorterAtom = atom( + get => { + let pages = get(pagesAtom); + const sorterState = get(sorterStateAtom); + 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) => + sorterState.sortingFn(sortCtx, a, b); + pages = [...pages].sort(compareFn); + } + return { + pages, + ...sortCtx, + }; + }, + (_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => { + set(sorterStateAtom, sorterState => { + if (sorterState.key === newSortKey) { + return { + ...sorterState, + order: sorterState.order === 'asc' ? 'desc' : 'asc', + }; + } else { + return { + key: newSortKey, + order: 'desc', + sortingFn: sorterState.sortingFn, + }; + } + }); + } +); + +export const pageGroupsAtom = atom(get => { + let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy)); + const sorter = get(sorterAtom); + + if (groupBy === false) { + groupBy = undefined; + } else if (groupBy === undefined) { + groupBy = + sorter.key === 'createDate' || sorter.key === 'updatedDate' + ? sorter.key + : // default sort + !sorter.key + ? DEFAULT_SORT_KEY + : undefined; + } + return pagesToPageGroups(sorter.pages, groupBy); +}); diff --git a/packages/frontend/component/src/components/page-list/styles.ts b/packages/frontend/component/src/components/page-list/styles.ts deleted file mode 100644 index 1cfbab92bc..0000000000 --- a/packages/frontend/component/src/components/page-list/styles.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { displayFlex, styled } from '../../styles'; -import { Content } from '../../ui/layout/content'; -import { TableBodyRow } from '../../ui/table/table-row'; - -export const StyledTableContainer = styled('div')(({ theme }) => { - return { - height: '100%', - minHeight: '600px', - padding: '0 32px 180px 32px', - maxWidth: '100%', - [theme.breakpoints.down('sm')]: { - padding: '52px 0px', - 'tr > td:first-of-type': { - borderTopLeftRadius: '0px', - borderBottomLeftRadius: '0px', - }, - 'tr > td:last-of-type': { - borderTopRightRadius: '0px', - borderBottomRightRadius: '0px', - }, - }, - }; -}); - -/** - * @deprecated - */ -export const StyledTitleWrapper = styled('div')(() => { - return { - ...displayFlex('flex-start', 'center'), - a: { - color: 'inherit', - }, - 'a:visited': { - color: 'unset', - }, - 'a:hover': { - color: 'var(--affine-primary-color)', - }, - }; -}); -export const StyledTitleLink = styled('div')(() => { - return { - ...displayFlex('flex-start', 'center'), - color: 'var(--affine-text-primary-color)', - '>svg': { - fontSize: '24px', - marginRight: '12px', - color: 'var(--affine-icon-color)', - }, - }; -}); - -export const StyledTitleContentWrapper = styled('div')(() => { - return { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - width: '100%', - overflow: 'hidden', - }; -}); - -export const StyledTitlePreview = styled(Content)(() => { - return { - fontWeight: 400, - fontSize: 'var(--affine-font-xs)', - maxWidth: '100%', - }; -}); - -export const StyledTableBodyRow = styled(TableBodyRow)(() => { - return { - cursor: 'pointer', - '.favorite-button': { - visibility: 'hidden', - }, - '&:hover': { - '.favorite-button': { - visibility: 'visible', - }, - }, - }; -}); diff --git a/packages/frontend/component/src/components/page-list/type.ts b/packages/frontend/component/src/components/page-list/type.ts deleted file mode 100644 index e82e87d305..0000000000 --- a/packages/frontend/component/src/components/page-list/type.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { CollectionsAtom } from '@affine/component/page-list/use-collection-manager'; -import type { Tag } from '@affine/env/filter'; -import type { PropertiesMeta } from '@affine/env/filter'; -import type { GetPageInfoById } from '@affine/env/page-info'; -import type { ReactElement, ReactNode } from 'react'; - -/** - * Get the keys of an object type whose values are of a given type - * - * See https://stackoverflow.com/questions/54520676/in-typescript-how-to-get-the-keys-of-an-object-type-whose-values-are-of-a-given - */ -export type KeysMatching = { - [K in keyof T]-?: T[K] extends V ? K : never; -}[keyof T]; - -export type ListData = { - pageId: string; - icon: JSX.Element; - title: string; - preview?: ReactNode; - tags: Tag[]; - favorite: boolean; - createDate: Date; - updatedDate: Date; - isPublicPage: boolean; - onClickPage: () => void; - onOpenPageInNewTab: () => void; - bookmarkPage: () => void; - removeToTrash: () => void; - onDisablePublicSharing: () => void; -}; - -export type DateKey = KeysMatching; - -export type TrashListData = { - pageId: string; - icon: JSX.Element; - title: string; - preview?: ReactNode; - createDate: Date; - // TODO remove optional after assert that trashDate is always set - trashDate?: Date; - onClickPage: () => void; - onRestorePage: () => void; - onPermanentlyDeletePage: () => void; -}; - -export type PageListProps = { - isPublicWorkspace?: boolean; - collectionsAtom: CollectionsAtom; - list: ListData[]; - fallback?: ReactNode; - onCreateNewPage: () => void; - onCreateNewEdgeless: () => void; - onImportFile: () => void; - getPageInfo: GetPageInfoById; - propertiesMeta: PropertiesMeta; -}; - -export type DraggableTitleCellData = { - pageId: string; - pageTitle: string; - pagePreview?: string; - icon: ReactElement; -}; diff --git a/packages/frontend/component/src/components/page-list/types.ts b/packages/frontend/component/src/components/page-list/types.ts new file mode 100644 index 0000000000..8dbc9e77fb --- /dev/null +++ b/packages/frontend/component/src/components/page-list/types.ts @@ -0,0 +1,87 @@ +import type { Tag } from '@affine/env/filter'; +import type { PageMeta, Workspace } from '@blocksuite/store'; +import type { ReactNode } from 'react'; +import type { To } from 'react-router-dom'; + +// TODO: consider reducing the number of props here +// using type instead of interface to make it Record compatible +export type PageListItemProps = { + pageId: string; + icon: JSX.Element; + title: ReactNode; // using ReactNode to allow for rich content rendering + preview?: ReactNode; // using ReactNode to allow for rich content rendering + tags: Tag[]; + createDate: Date; + updatedDate?: Date; + isPublicPage?: boolean; + 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 PageListHeaderProps {} + +// todo: a temporary solution. may need to be refactored later +export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later + +// todo: a temporary solution. may need to be refactored later +export interface SortBy { + key: 'createDate' | 'updatedDate'; + order: 'asc' | 'desc'; +} + +export type DateKey = 'createDate' | 'updatedDate'; + +export interface PageListProps { + // required data: + pages: PageMeta[]; + 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; + clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate + 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; + draggable?: boolean; // whether or not to allow dragging this page item + onDragStart?: (pageId: string) => void; + onDragEnd?: (pageId: string) => void; + // 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; +} + +export interface PageListHandle { + toggleSelectable: () => void; +} + +export interface PageGroupDefinition { + id: string; + // using a function to render custom group header + label: (() => ReactNode) | ReactNode; + match: (item: PageMeta) => boolean; +} + +export interface PageGroupProps { + id: string; + label?: ReactNode; // if there is no label, it is a default group (without header) + items: PageMeta[]; + allItems: PageMeta[]; +} + +type MakeRecord = { + [P in keyof T]: T[P]; +}; + +export type PageMetaRecord = MakeRecord; + +export type DraggableTitleCellData = { + pageId: string; + pageTitle: ReactNode; +}; diff --git a/packages/frontend/component/src/components/page-list/use-collection-manager.ts b/packages/frontend/component/src/components/page-list/use-collection-manager.ts index 1744952f2f..6586bdb58e 100644 --- a/packages/frontend/component/src/components/page-list/use-collection-manager.ts +++ b/packages/frontend/component/src/components/page-list/use-collection-manager.ts @@ -1,137 +1,124 @@ -import type { Collection, Filter, VariableMap } from '@affine/env/filter'; -import { useAtom } from 'jotai'; -import { atomWithReset, RESET } from 'jotai/utils'; -import type { WritableAtom } from 'jotai/vanilla'; +import type { + Collection, + DeleteCollectionInfo, + Filter, + VariableMap, +} from '@affine/env/filter'; +import type { PageMeta } from '@blocksuite/store'; +import { type Atom, useAtom, useAtomValue } from 'jotai'; +import { atomWithReset } from 'jotai/utils'; import { useCallback } from 'react'; import { NIL } from 'uuid'; import { evalFilterList } from './filter'; -const defaultCollection = { - id: NIL, - name: 'All', - filterList: [], - workspaceId: 'temporary', +export const createEmptyCollection = ( + id: string, + data?: Partial> +): Collection => { + return { + id, + name: '', + mode: 'page', + filterList: [], + pages: [], + allowList: [], + ...data, + }; }; - -const collectionAtom = atomWithReset<{ - currentId: string; - defaultCollection: Collection; -}>({ - currentId: NIL, - defaultCollection: defaultCollection, +const defaultCollection: Collection = createEmptyCollection(NIL, { + name: 'All', + mode: 'rule', }); +const defaultCollectionAtom = atomWithReset(defaultCollection); +export const currentCollectionAtom = atomWithReset(NIL); -export type CollectionsAtom = WritableAtom< - Collection[] | Promise, - [Collection[] | ((collection: Collection[]) => Collection[])], - Promise ->; +export type Updater = (value: T) => T; +export type CollectionUpdater = Updater; +export type CollectionsCRUD = { + addCollection: (...collections: Collection[]) => Promise; + collections: Collection[]; + updateCollection: (id: string, updater: CollectionUpdater) => Promise; + deleteCollection: ( + info: DeleteCollectionInfo, + ...ids: string[] + ) => Promise; +}; +export type CollectionsCRUDAtom = Atom; -export const useSavedCollections = (collectionAtom: CollectionsAtom) => { - const [savedCollections, setCollections] = useAtom(collectionAtom); - - const saveCollection = useCallback( - async (collection: Collection) => { - if (collection.id === NIL) { - return; - } - await setCollections(old => [...old, collection]); - }, - [setCollections] - ); - const deleteCollection = useCallback( - async (id: string) => { - if (id === NIL) { - return; - } - await setCollections(old => old.filter(v => v.id !== id)); - }, - [setCollections] - ); +export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => { + const [{ collections, addCollection, deleteCollection, updateCollection }] = + useAtom(collectionAtom); const addPage = useCallback( async (collectionId: string, pageId: string) => { - await setCollections(old => { - const collection = old.find(v => v.id === collectionId); - if (!collection) { - return old; + await updateCollection(collectionId, old => { + if (old.mode === 'page') { + return { + ...old, + pages: [pageId, ...(old.pages ?? [])], + }; } - return [ - ...old.filter(v => v.id !== collectionId), - { - ...collection, - allowList: [pageId, ...(collection.allowList ?? [])], - }, - ]; + return { + ...old, + allowList: [pageId, ...(old.allowList ?? [])], + }; }); }, - [setCollections] + [updateCollection] ); return { - savedCollections, - saveCollection, + collections, + addCollection, + updateCollection, deleteCollection, addPage, }; }; -export const useCollectionManager = (collectionsAtom: CollectionsAtom) => { - const { savedCollections, saveCollection, deleteCollection, addPage } = - useSavedCollections(collectionsAtom); - const [collectionData, setCollectionData] = useAtom(collectionAtom); - - const updateCollection = useCallback( +export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { + const { + collections, + updateCollection, + addCollection, + deleteCollection, + addPage, + } = useSavedCollections(collectionsAtom); + const currentCollectionId = useAtomValue(currentCollectionAtom); + const [defaultCollection, updateDefaultCollection] = useAtom( + defaultCollectionAtom + ); + const update = useCallback( async (collection: Collection) => { if (collection.id === NIL) { - setCollectionData({ - ...collectionData, - defaultCollection: collection, - }); + updateDefaultCollection(collection); } else { - await saveCollection(collection); + await updateCollection(collection.id, () => collection); } }, - [collectionData, saveCollection, setCollectionData] + [updateDefaultCollection, updateCollection] ); - const selectCollection = useCallback( - (id: string) => { - setCollectionData({ - ...collectionData, - currentId: id, - }); - }, - [collectionData, setCollectionData] - ); - const backToAll = useCallback(() => { - setCollectionData(RESET); - }, [setCollectionData]); const setTemporaryFilter = useCallback( (filterList: Filter[]) => { - setCollectionData({ - currentId: NIL, - defaultCollection: { - ...defaultCollection, - filterList: filterList, - }, + updateDefaultCollection({ + ...defaultCollection, + filterList: filterList, }); }, - [setCollectionData] + [updateDefaultCollection, defaultCollection] ); const currentCollection = - collectionData.currentId === NIL - ? collectionData.defaultCollection - : savedCollections.find(v => v.id === collectionData.currentId) ?? - collectionData.defaultCollection; + currentCollectionId === NIL + ? defaultCollection + : collections.find(v => v.id === currentCollectionId) ?? + defaultCollection; return { currentCollection: currentCollection, - savedCollections, - isDefault: currentCollection.id === NIL, + savedCollections: collections, + isDefault: currentCollectionId === NIL, // actions - saveCollection, - updateCollection, - selectCollection, - backToAll, + createCollection: addCollection, + updateCollection: update, deleteCollection, addPage, setTemporaryFilter, @@ -139,3 +126,25 @@ export const useCollectionManager = (collectionsAtom: CollectionsAtom) => { }; export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) => evalFilterList(filterList, varMap); + +export const filterPage = (collection: Collection, page: PageMeta) => { + if (collection.mode === 'page') { + return collection.pages.includes(page.id); + } + return filterPageByRules(collection.filterList, collection.allowList, page); +}; +export const filterPageByRules = ( + rules: Filter[], + allowList: string[], + page: PageMeta +) => { + if (allowList?.includes(page.id)) { + return true; + } + return filterByFilterList(rules, { + 'Is Favourited': !!page.favorite, + Created: page.createDate, + Updated: page.updatedDate ?? page.createDate, + Tags: page.tags, + }); +}; diff --git a/packages/frontend/component/src/components/page-list/use-date-group.tsx b/packages/frontend/component/src/components/page-list/use-date-group.tsx deleted file mode 100644 index ba403f4d35..0000000000 --- a/packages/frontend/component/src/components/page-list/use-date-group.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useAFFiNEI18N } from '@affine/i18n/hooks'; - -import type { DateKey, ListData } from './type'; -import { - isLastMonth, - isLastWeek, - isLastYear, - isToday, - isYesterday, -} from './utils'; - -export const useDateGroup = ({ - data, - key, -}: { - data: ListData[]; - key?: DateKey; -}) => { - const t = useAFFiNEI18N(); - if (!key) { - return data.map(item => ({ ...item, groupName: '' })); - } - - const fallbackGroup = { - id: 'earlier', - label: t['com.affine.earlier'](), - match: (_date: Date) => true, - }; - - const groups = [ - { - id: 'today', - label: t['com.affine.today'](), - match: (date: Date) => isToday(date), - }, - { - id: 'yesterday', - label: t['com.affine.yesterday'](), - match: (date: Date) => isYesterday(date) && !isToday(date), - }, - { - id: 'last7Days', - label: t['com.affine.last7Days'](), - match: (date: Date) => isLastWeek(date) && !isYesterday(date), - }, - { - id: 'last30Days', - label: t['com.affine.last30Days'](), - match: (date: Date) => isLastMonth(date) && !isLastWeek(date), - }, - { - id: 'currentYear', - label: t['com.affine.currentYear'](), - match: (date: Date) => isLastYear(date) && !isLastMonth(date), - }, - ] as const; - - return data.map(item => { - const group = groups.find(group => group.match(item[key])) ?? fallbackGroup; - return { - ...item, - groupName: group.label, - }; - }); -}; diff --git a/packages/frontend/component/src/components/page-list/utils.tsx b/packages/frontend/component/src/components/page-list/utils.tsx index 071239b505..0620139f4e 100644 --- a/packages/frontend/component/src/components/page-list/utils.tsx +++ b/packages/frontend/component/src/components/page-list/utils.tsx @@ -1,4 +1,12 @@ import { useMediaQuery, useTheme } from '@mui/material'; +import clsx from 'clsx'; +import { + type BaseSyntheticEvent, + forwardRef, + type PropsWithChildren, +} from 'react'; + +import * as styles from './page-list.css'; export const useIsSmallDevices = () => { const theme = useTheme(); @@ -69,3 +77,71 @@ export const formatDate = (date: Date): string => { // MM-DD HH:mm 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( + { + flex, + alignment, + hideInSmallContainer, + className, + style, + children, + ...rest + }: ColWrapperProps, + ref + ) { + return ( +
+ {children} +
+ ); + } +); + +export const withinDaysAgo = (date: Date, days: number): boolean => { + const startDate = new Date(); + const day = startDate.getDay(); + const month = startDate.getMonth(); + const year = startDate.getFullYear(); + return new Date(year, month, day - days) <= date; +}; + +export const betweenDaysAgo = ( + date: Date, + days0: number, + days1: number +): boolean => { + return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1); +}; + +export function stopPropagation(event: BaseSyntheticEvent) { + event.stopPropagation(); + event.preventDefault(); +} +export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) { + event.stopPropagation(); +} diff --git a/packages/frontend/component/src/components/page-list/view/affine-shape.tsx b/packages/frontend/component/src/components/page-list/view/affine-shape.tsx new file mode 100644 index 0000000000..474dbafc24 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/affine-shape.tsx @@ -0,0 +1,133 @@ +import type React from 'react'; + +export const AffineShapeIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/frontend/component/src/components/page-list/view/collection-bar.css.ts b/packages/frontend/component/src/components/page-list/view/collection-bar.css.ts index c2a678335b..5621f96469 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-bar.css.ts +++ b/packages/frontend/component/src/components/page-list/view/collection-bar.css.ts @@ -1,7 +1,5 @@ import { style } from '@vanilla-extract/css'; -import { viewMenu } from './collection-list.css'; - export const view = style({ display: 'flex', alignItems: 'center', @@ -9,7 +7,6 @@ export const view = style({ fontSize: 14, fontWeight: 600, height: '100%', - paddingLeft: 16, }); export const option = style({ @@ -29,28 +26,3 @@ export const option = style({ }, }, }); -export const pin = style({ - opacity: 1, -}); -export const pinedIcon = style({ - display: 'block', - selectors: { - [`${option}:hover &`]: { - display: 'none', - }, - [`${viewMenu}:hover &`]: { - display: 'none', - }, - }, -}); -export const pinIcon = style({ - display: 'none', - selectors: { - [`${option}:hover &`]: { - display: 'block', - }, - [`${viewMenu}:hover &`]: { - display: 'block', - }, - }, -}); diff --git a/packages/frontend/component/src/components/page-list/view/collection-bar.tsx b/packages/frontend/component/src/components/page-list/view/collection-bar.tsx index e6a4e0c9c7..d7f0c6434f 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-bar.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-bar.tsx @@ -1,4 +1,4 @@ -import type { PropertiesMeta } from '@affine/env/filter'; +import type { DeleteCollectionInfo, PropertiesMeta } from '@affine/env/filter'; import type { GetPageInfoById } from '@affine/env/page-info'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ViewLayersIcon } from '@blocksuite/icons'; @@ -8,22 +8,24 @@ import clsx from 'clsx'; import { useState } from 'react'; import { - type CollectionsAtom, + type CollectionsCRUDAtom, useCollectionManager, } from '../use-collection-manager'; import * as styles from './collection-bar.css'; -import { EditCollectionModal } from './create-collection'; +import { type AllPageListConfig, EditCollectionModal } from './edit-collection'; import { useActions } from './use-action'; interface CollectionBarProps { getPageInfo: GetPageInfoById; propertiesMeta: PropertiesMeta; - collectionsAtom: CollectionsAtom; - columnsCount: number; + collectionsAtom: CollectionsCRUDAtom; + backToAll: () => void; + allPageListConfig: AllPageListConfig; + info: DeleteCollectionInfo; } export const CollectionBar = (props: CollectionBarProps) => { - const { getPageInfo, propertiesMeta, columnsCount, collectionsAtom } = props; + const { collectionsAtom } = props; const t = useAFFiNEI18N(); const setting = useCollectionManager(collectionsAtom); const collection = setting.currentCollection; @@ -31,16 +33,23 @@ export const CollectionBar = (props: CollectionBarProps) => { const actions = useActions({ collection, setting, + info: props.info, openEdit: () => setOpen(true), }); - return !setting.isDefault ? ( - - +
+
{ ); })}
- - {Array.from({ length: columnsCount - 2 }).map((_, i) => ( - - ))} - +
{ > - - +
+
) : null; }; diff --git a/packages/frontend/component/src/components/page-list/view/collection-list.css.ts b/packages/frontend/component/src/components/page-list/view/collection-list.css.ts index fe997f45c5..8e73a8d392 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-list.css.ts +++ b/packages/frontend/component/src/components/page-list/view/collection-list.css.ts @@ -1,4 +1,4 @@ -import { globalStyle, style } from '@vanilla-extract/css'; +import { style } from '@vanilla-extract/css'; export const menuTitleStyle = style({ marginLeft: '12px', @@ -14,30 +14,6 @@ export const menuDividerStyle = style({ height: '1px', background: 'var(--affine-border-color)', }); -export const viewButton = style({ - borderRadius: '8px', - height: '100%', - padding: '4px 8px', - fontSize: 'var(--affine-font-xs)', - background: 'var(--affine-white)', - ['WebkitAppRegion' as string]: 'no-drag', - maxWidth: '150px', - color: 'var(--affine-text-secondary-color)', - border: '1px solid var(--affine-border-color)', - transition: 'margin-left 0.2s ease-in-out', - ':hover': { - borderColor: 'var(--affine-border-color)', - background: 'var(--affine-hover-color)', - }, - marginRight: '20px', -}); -globalStyle(`${viewButton} > span`, { - display: 'block', - width: '100%', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}); export const viewMenu = style({}); export const viewOption = style({ borderRadius: 8, @@ -57,171 +33,9 @@ export const viewOption = style({ }, }, }); -export const deleteOption = style({ - ':hover': { - backgroundColor: '#FFEFE9', - }, -}); -export const filterButton = style({ - borderRadius: '8px', - height: '100%', - width: '100%', - marginRight: '20px', - padding: '4px 8px', - fontSize: 'var(--affine-font-xs)', - background: 'var(--affine-white)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: 'var(--affine-text-secondary-color)', - border: '1px solid var(--affine-border-color)', - ['WebkitAppRegion' as string]: 'no-drag', - transition: 'margin-left 0.2s ease-in-out', - ':hover': { - borderColor: 'var(--affine-border-color)', - background: 'var(--affine-hover-color)', - }, -}); -export const filterButtonCollapse = style({ - marginLeft: '20px', -}); -export const viewDivider = style({ - '::after': { - content: '""', - display: 'block', - width: '100%', - height: '1px', - background: 'var(--affine-border-color)', - position: 'absolute', - bottom: 0, - left: 0, - margin: '0 1px', - }, -}); -export const saveButton = style({ - marginTop: '4px', - borderRadius: '8px', - padding: '8px 0', - ':hover': { - background: 'var(--affine-hover-color)', - color: 'var(--affine-text-primary-color)', - border: '1px solid var(--affine-border-color)', - }, -}); -export const saveButtonContainer = style({ - display: 'flex', - alignItems: 'center', - cursor: 'pointer', - width: '100%', - height: '100%', - padding: '8px', -}); -export const saveIcon = style({ - display: 'flex', - alignItems: 'center', - fontSize: 'var(--affine-font-sm)', - marginRight: '8px', -}); -export const saveText = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: 'var(--affine-font-sm)', -}); -export const cancelButton = style({ - background: 'var(--affine-hover-color)', - borderRadius: '8px', - ':hover': { - background: 'var(--affine-hover-color)', - color: 'var(--affine-text-primary-color)', - border: '1px solid var(--affine-border-color)', - }, -}); -export const saveTitle = style({ - fontSize: 'var(--affine-font-h-6)', - fontWeight: '600', - lineHeight: '24px', - paddingBottom: 20, -}); -export const allowList = style({}); - -export const allowTitle = style({ - fontSize: 12, - margin: '20px 0', -}); - -export const allowListContent = style({ - margin: '8px 0', -}); - -export const excludeList = style({ - backgroundColor: 'var(--affine-background-warning-color)', - padding: 18, - borderRadius: 8, -}); - -export const excludeListContent = style({ - margin: '8px 0', -}); - -export const filterTitle = style({ - fontSize: 12, - fontWeight: 600, - marginBottom: 10, -}); - -export const excludeTitle = style({ - fontSize: 12, - fontWeight: 600, -}); - -export const excludeTip = style({ - color: 'var(--affine-text-secondary-color)', - fontSize: 12, -}); - -export const scrollContainer = style({ - maxHeight: '70vh', - flex: 1, - display: 'flex', - flexDirection: 'column', -}); -export const container = style({ - display: 'flex', - flexDirection: 'column', -}); -export const pageContainer = style({ - fontSize: 14, - fontWeight: 600, - height: 32, - display: 'flex', - alignItems: 'center', - paddingLeft: 8, - paddingRight: 5, -}); - -export const pageIcon = style({ - marginRight: 20, - display: 'flex', - alignItems: 'center', -}); - -export const pageTitle = style({ - flex: 1, -}); -export const deleteIcon = style({ - marginLeft: 20, - display: 'flex', - alignItems: 'center', - borderRadius: 4, - padding: 4, - cursor: 'pointer', - ':hover': { - color: 'var(--affine-error-color)', - backgroundColor: 'var(--affine-background-error-color)', - }, -}); export const filterMenuTrigger = style({ padding: '6px 8px', - background: 'var(--affine-hover-color)', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, }); diff --git a/packages/frontend/component/src/components/page-list/view/collection-list.tsx b/packages/frontend/component/src/components/page-list/view/collection-list.tsx index 9414d29675..eefae9aba5 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-list.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-list.tsx @@ -1,114 +1,32 @@ -import type { Collection, Filter } from '@affine/env/filter'; +import type { + Collection, + DeleteCollectionInfo, + Filter, +} from '@affine/env/filter'; import type { PropertiesMeta } from '@affine/env/filter'; -import type { GetPageInfoById } from '@affine/env/page-info'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { FilteredIcon, FolderIcon, ViewLayersIcon } from '@blocksuite/icons'; +import { FilteredIcon } from '@blocksuite/icons'; import { Button } from '@toeverything/components/button'; -import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu'; -import { Tooltip } from '@toeverything/components/tooltip'; -import clsx from 'clsx'; -import type { MouseEvent } from 'react'; +import { Menu } from '@toeverything/components/menu'; import { useCallback, useState } from 'react'; import { FlexWrapper } from '../../../ui/layout'; import { CreateFilterMenu } from '../filter/vars'; import type { useCollectionManager } from '../use-collection-manager'; import * as styles from './collection-list.css'; -import { EditCollectionModal } from './create-collection'; -import { useActions } from './use-action'; +import { CollectionOperations } from './collection-operations'; +import { type AllPageListConfig, EditCollectionModal } from './edit-collection'; -const CollectionOption = ({ - collection, - setting, - updateCollection, -}: { - collection: Collection; - setting: ReturnType; - updateCollection: (view: Collection) => void; -}) => { - const actions = useActions({ - collection, - setting, - openEdit: updateCollection, - }); - - const selectCollection = useCallback( - () => setting.selectCollection(collection.id), - [setting, collection.id] - ); - return ( - - - - } - onClick={selectCollection} - key={collection.id} - className={styles.viewMenu} - > - -
-
- {collection.name} -
-
- {actions.map((action, i) => { - const onClick = (e: MouseEvent) => { - e.stopPropagation(); - action.click(); - }; - - return ( -
- {action.icon} -
- ); - })} -
-
-
-
- ); -}; export const CollectionList = ({ setting, - getPageInfo, propertiesMeta, + allPageListConfig, + userInfo, }: { setting: ReturnType; - getPageInfo: GetPageInfoById; propertiesMeta: PropertiesMeta; + allPageListConfig: AllPageListConfig; + userInfo: DeleteCollectionInfo; }) => { const t = useAFFiNEI18N(); const [collection, setCollection] = useState(); @@ -140,83 +58,51 @@ export const CollectionList = ({ ); return ( - {setting.savedCollections.length > 0 && ( - - - - - } - onClick={setting.backToAll} - className={styles.viewMenu} - > -
-
All
-
-
-
Saved Collection
-
- {setting.savedCollections.map(view => ( - - ))} -
- } + {setting.isDefault ? ( + <> + + } + > + + + + + ) : ( + - + )} - - } - > - - - ); }; diff --git a/packages/frontend/component/src/components/page-list/view/collection-operations.css.ts b/packages/frontend/component/src/components/page-list/view/collection-operations.css.ts new file mode 100644 index 0000000000..ef15cdc0a8 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/collection-operations.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const divider = style({ + marginTop: '2px', + marginBottom: '2px', + marginLeft: '12px', + marginRight: '8px', + height: '1px', + background: 'var(--affine-border-color)', +}); diff --git a/packages/frontend/component/src/components/page-list/view/collection-operations.tsx b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx new file mode 100644 index 0000000000..c7deb837a4 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx @@ -0,0 +1,145 @@ +import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { DeleteIcon, EditIcon, FilterIcon } from '@blocksuite/icons'; +import { + Menu, + MenuIcon, + MenuItem, + type MenuItemProps, +} from '@toeverything/components/menu'; +import { + type PropsWithChildren, + type ReactElement, + useCallback, + useMemo, +} from 'react'; + +import type { useCollectionManager } from '../use-collection-manager'; +import type { AllPageListConfig } from '.'; +import * as styles from './collection-operations.css'; +import { + useEditCollection, + useEditCollectionName, +} from './use-edit-collection'; + +export const CollectionOperations = ({ + collection, + config, + setting, + info, + children, +}: PropsWithChildren<{ + info: DeleteCollectionInfo; + collection: Collection; + config: AllPageListConfig; + setting: ReturnType; +}>) => { + const { open: openEditCollectionModal, node: editModal } = + useEditCollection(config); + const t = useAFFiNEI18N(); + const { open: openEditCollectionNameModal, node: editNameModal } = + useEditCollectionName({ + title: t['com.affine.editCollection.renameCollection'](), + }); + const showEditName = useCallback(() => { + openEditCollectionNameModal(collection.name) + .then(name => { + return setting.updateCollection({ ...collection, name }); + }) + .catch(err => { + console.error(err); + }); + }, [openEditCollectionNameModal, collection, setting]); + const showEdit = useCallback(() => { + openEditCollectionModal(collection) + .then(collection => { + return setting.updateCollection(collection); + }) + .catch(err => { + console.error(err); + }); + }, [setting, collection, openEditCollectionModal]); + const actions = useMemo< + Array< + | { + icon: ReactElement; + name: string; + click: () => void; + type?: MenuItemProps['type']; + element?: undefined; + } + | { + element: ReactElement; + } + > + >( + () => [ + { + icon: ( + + + + ), + name: t['com.affine.collection.menu.rename'](), + click: showEditName, + }, + { + icon: ( + + + + ), + name: t['com.affine.collection.menu.edit'](), + click: showEdit, + }, + { + element:
, + }, + { + icon: ( + + + + ), + name: t['Delete'](), + click: () => { + setting.deleteCollection(info, collection.id).catch(err => { + console.error(err); + }); + }, + type: 'danger', + }, + ], + [t, showEditName, showEdit, setting, info, collection.id] + ); + return ( + <> + {editModal} + {editNameModal} + + {actions.map(action => { + if (action.element) { + return action.element; + } + return ( + + {action.name} + + ); + })} +
+ } + > + {children} + + + ); +}; diff --git a/packages/frontend/component/src/components/page-list/view/create-collection.css.ts b/packages/frontend/component/src/components/page-list/view/create-collection.css.ts new file mode 100644 index 0000000000..e239a5ca23 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/create-collection.css.ts @@ -0,0 +1,28 @@ +import { style } from '@vanilla-extract/css'; + +export const footer = style({ + display: 'flex', + justifyContent: 'flex-end', + paddingTop: 20, + gap: 20, +}); + +export const createTips = style({ + color: 'var(--affine-text-secondary-color)', + fontSize: 12, + lineHeight: '20px', +}); + +export const label = style({ + color: 'var(--affine-text-secondary-color)', + fontSize: 14, + lineHeight: '22px', +}); + +export const content = style({ + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: '12px 0px 20px', + marginBottom: 8, +}); diff --git a/packages/frontend/component/src/components/page-list/view/create-collection.tsx b/packages/frontend/component/src/components/page-list/view/create-collection.tsx index b1ced0842d..2fc26e93b8 100644 --- a/packages/frontend/component/src/components/page-list/view/create-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/create-collection.tsx @@ -1,45 +1,40 @@ -import type { Collection, Filter } from '@affine/env/filter'; -import type { PropertiesMeta } from '@affine/env/filter'; -import type { GetPageInfoById } from '@affine/env/page-info'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { - EdgelessIcon, - PageIcon, - RemoveIcon, - SaveIcon, -} from '@blocksuite/icons'; + createEmptyCollection, + useEditCollectionName, +} from '@affine/component/page-list'; +import type { Collection } from '@affine/env/filter'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { SaveIcon } from '@blocksuite/icons'; import { Button } from '@toeverything/components/button'; import { Modal } from '@toeverything/components/modal'; import { nanoid } from 'nanoid'; import { useCallback, useMemo, useState } from 'react'; -import { Input, ScrollableContainer } from '../../..'; -import { FilterList } from '../filter'; -import * as styles from './collection-list.css'; +import Input from '../../../ui/input'; +import * as styles from './create-collection.css'; -interface EditCollectionModalProps { - init?: Collection; +export interface CreateCollectionModalProps { title?: string; + onConfirmText?: string; + init: string; + onConfirm: (title: string) => Promise; open: boolean; - getPageInfo: GetPageInfoById; - propertiesMeta: PropertiesMeta; + showTips?: boolean; onOpenChange: (open: boolean) => void; - onConfirm: (view: Collection) => Promise; } -export const EditCollectionModal = ({ +export const CreateCollectionModal = ({ init, onConfirm, open, + showTips, onOpenChange, - getPageInfo, - propertiesMeta, title, -}: EditCollectionModalProps) => { +}: CreateCollectionModalProps) => { const t = useAFFiNEI18N(); - const onConfirmOnCollection = useCallback( - (view: Collection) => { - onConfirm(view) + const onConfirmTitle = useCallback( + (title: string) => { + onConfirm(title) .then(() => { onOpenChange(false); }) @@ -54,206 +49,73 @@ export const EditCollectionModal = ({ }, [onOpenChange]); return ( - - {init ? ( - + {init != null ? ( + ) : null} ); }; -interface PageProps { - id: string; - getPageInfo: GetPageInfoById; - onClick: (id: string) => void; -} - -const Page = ({ id, onClick, getPageInfo }: PageProps) => { - const page = getPageInfo(id); - const handleClick = useCallback(() => onClick(id), [id, onClick]); - return ( - <> - {page ? ( -
-
- {page.isEdgeless ? ( - - ) : ( - - )} -
-
{page.title}
-
- -
-
- ) : null} - - ); -}; - -interface EditCollectionProps { - title?: string; +export interface CreateCollectionProps { onConfirmText?: string; - init: Collection; - getPageInfo: GetPageInfoById; - propertiesMeta: PropertiesMeta; + init: string; + showTips?: boolean; onCancel: () => void; - onConfirm: (collection: Collection) => void; + onConfirm: (title: string) => void; } -export const EditCollection = ({ - title, - init, - onConfirm, - onCancel, +export const CreateCollection = ({ onConfirmText, - getPageInfo, - propertiesMeta, -}: EditCollectionProps) => { + init, + showTips, + onCancel, + onConfirm, +}: CreateCollectionProps) => { const t = useAFFiNEI18N(); - const [value, onChange] = useState(init); - const removeFromExcludeList = useCallback( - (id: string) => { - onChange({ - ...value, - excludeList: value.excludeList?.filter(v => v !== id), - }); - }, - [value] - ); - const removeFromAllowList = useCallback( - (id: string) => { - onChange({ - ...value, - allowList: value.allowList?.filter(v => v !== id), - }); - }, - [value] - ); - const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]); - const onSaveCollection = useCallback(() => { - if (!isNameEmpty) { - onConfirm(value); + const [value, onChange] = useState(init); + const isNameEmpty = useMemo(() => value.trim().length === 0, [value]); + const save = useCallback(() => { + if (isNameEmpty) { + return; } - }, [value, isNameEmpty, onConfirm]); + onConfirm(value); + }, [onConfirm, value, isNameEmpty]); return ( -
-
- {title ?? t['com.affine.editCollection.updateCollection']()} -
- - {value.excludeList?.length ? ( -
-
- Exclude from this collection -
-
- {value.excludeList.map(id => { - return ( - - ); - })} -
-
- These pages will never appear in the current collection -
+
+
+
Name
+ onChange(value), [onChange])} + onEnter={save} + > + {showTips ? ( +
+ Collection is a smart folder where you can manually add pages or + automatically add pages through rules.
) : null} -
-
- {t['com.affine.editCollection.filters']()} -
- onChange({ ...value, filterList })} - /> - {value.allowList ? ( -
-
With follow pages:
-
- {value.allowList.map(id => { - return ( - - ); - })} -
-
- ) : null} -
-
- onChange({ ...value, name })} - onEnter={onSaveCollection} - /> -
- -
+
+
@@ -262,33 +124,27 @@ export const EditCollection = ({ ); }; -interface SaveCollectionButtonProps { - getPageInfo: GetPageInfoById; - propertiesMeta: PropertiesMeta; - filterList: Filter[]; - workspaceId: string; +interface SaveAsCollectionButtonProps { onConfirm: (collection: Collection) => Promise; } -export const SaveCollectionButton = ({ +export const SaveAsCollectionButton = ({ onConfirm, - getPageInfo, - propertiesMeta, - filterList, - workspaceId, -}: SaveCollectionButtonProps) => { - const [show, changeShow] = useState(false); - const [init, setInit] = useState(); - const handleClick = useCallback(() => { - changeShow(true); - setInit({ - id: nanoid(), - name: '', - filterList, - workspaceId, - }); - }, [changeShow, workspaceId, filterList]); +}: SaveAsCollectionButtonProps) => { const t = useAFFiNEI18N(); + const { open, node } = useEditCollectionName({ + title: t['com.affine.editCollection.saveCollection'](), + showTips: true, + }); + const handleClick = useCallback(() => { + open('') + .then(name => { + return onConfirm(createEmptyCollection(nanoid(), { name })); + }) + .catch(err => { + console.error(err); + }); + }, [open, onConfirm]); return ( <> - + {node} ); }; diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection.css.ts b/packages/frontend/component/src/components/page-list/view/edit-collection.css.ts new file mode 100644 index 0000000000..01e8e3a8c7 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/edit-collection.css.ts @@ -0,0 +1,228 @@ +import { style } from '@vanilla-extract/css'; + +export const ellipsis = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const pagesBottomLeft = style({ + display: 'flex', + gap: 8, + alignItems: 'center', +}); + +export const pagesBottom = style({ + display: 'flex', + justifyContent: 'space-between', + padding: '20px 24px', + borderTop: '1px solid var(--affine-border-color)', + flexWrap: 'wrap', + gap: '12px', +}); + +export const pagesTabContent = style({ + display: 'flex', + justifyContent: 'space-between', + gap: 8, + alignItems: 'center', + padding: '16px 16px 8px 16px', +}); + +export const pagesTab = style({ + flex: 1, + display: 'flex', + flexDirection: 'column', + width: '100%', + overflow: 'hidden', +}); + +export const pagesList = style({ + display: 'flex', + flex: 1, + overflow: 'hidden', +}); + +export const bottomLeft = style({ + display: 'flex', + gap: 8, + alignItems: 'center', +}); + +export const rulesBottom = style({ + display: 'flex', + justifyContent: 'space-between', + padding: '20px 24px', + borderTop: '1px solid var(--affine-border-color)', + flexWrap: 'wrap', + gap: '12px', +}); + +export const includeListTitle = style({ + marginTop: 8, + fontSize: 14, + fontWeight: 400, + lineHeight: '22px', + color: 'var(--affine-text-secondary-color)', + paddingLeft: 18, +}); + +export const rulesContainerRight = style({ + flex: 2, + flexDirection: 'column', + borderLeft: '1px solid var(--affine-border-color)', + overflowX: 'hidden', + overflowY: 'auto', +}); + +export const includeAddButton = style({ + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '4px 8px', + fontSize: 14, + lineHeight: '22px', + width: 'max-content', +}); + +export const includeItemTitle = style({ overflow: 'hidden', fontWeight: 600 }); + +export const includeItemContentIs = style({ + padding: '0 8px', + color: 'var(--affine-text-secondary-color)', +}); + +export const includeItemContent = style({ + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 12, + lineHeight: '20px', + overflow: 'hidden', +}); + +export const includeItem = style({ + display: 'flex', + alignItems: 'center', + width: 'max-content', + backgroundColor: 'var(--affine-background-primary-color)', + overflow: 'hidden', + gap: 16, + whiteSpace: 'nowrap', + border: '1px solid var(--affine-border-color)', + borderRadius: 8, + padding: '4px 8px 4px', +}); + +export const includeTitle = style({ + display: 'flex', + alignItems: 'center', + gap: 10, + fontSize: 14, + lineHeight: '22px', +}); + +export const rulesContainerLeftContentInclude = style({ + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + gap: 8, + flexShrink: 0, +}); + +export const rulesContainerLeftContent = style({ + padding: '12px 16px 16px', + display: 'flex', + flexDirection: 'column', + flex: 1, + overflow: 'hidden', +}); + +export const rulesContainerLeftTab = style({ + display: 'flex', + justifyContent: 'space-between', + gap: 8, + alignItems: 'center', + padding: '16px 16px 8px 16px', +}); + +export const rulesContainerLeft = style({ + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}); + +export const rulesContainer = style({ + display: 'flex', + overflow: 'hidden', + flex: 1, +}); + +export const collectionEditContainer = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', +}); + +export const confirmButton = style({ + marginLeft: 20, +}); + +export const resultPages = style({ + width: '100%', +}); + +export const pageList = style({ + width: '100%', +}); + +export const previewCountTipsHighlight = style({ + color: 'var(--affine-primary-color)', +}); + +export const previewCountTips = style({ + fontSize: 12, + lineHeight: '20px', + color: 'var(--affine-text-secondary-color)', +}); +export const selectedCountTips = style({ + fontSize: 12, + lineHeight: '20px', + color: 'var(--affine-text-primary-color)', +}); + +export const rulesTitleHighlight = style({ + color: 'var(--affine-primary-color)', + fontStyle: 'italic', + fontWeight: 800, +}); + +export const tabButton = style({ height: 28 }); +export const icon = style({ + color: 'var(--affine-icon-color)', +}); +export const button = style({ + userSelect: 'none', + borderRadius: 4, + cursor: 'pointer', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, +}); +export const bottomButton = style({ + padding: '4px 12px', + borderRadius: 8, +}); + +export const previewActive = style({ + backgroundColor: 'var(--affine-hover-color-filled)', +}); +export const rulesTitle = style({ + padding: '20px 24px', + userSelect: 'none', + fontSize: 20, + lineHeight: '24px', + color: 'var(--affine-text-secondary-color)', + borderBottom: '1px solid var(--affine-border-color)', +}); diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection.tsx new file mode 100644 index 0000000000..9f34733e35 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/edit-collection.tsx @@ -0,0 +1,913 @@ +import { + AffineShapeIcon, + PageList, + PageListScrollContainer, +} from '@affine/component/page-list'; +import type { Collection, Filter } from '@affine/env/filter'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + CloseIcon, + EdgelessIcon, + FilterIcon, + PageIcon, + PlusIcon, + ToggleCollapseIcon, +} from '@blocksuite/icons'; +import type { PageMeta, Workspace } from '@blocksuite/store'; +import { Button } from '@toeverything/components/button'; +import { Menu } from '@toeverything/components/menu'; +import { Modal } from '@toeverything/components/modal'; +import clsx from 'clsx'; +import { type MouseEvent, useEffect } from 'react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; + +import { RadioButton, RadioButtonGroup } from '../../..'; +import { FilterList } from '../filter'; +import { VariableSelect } from '../filter/vars'; +import { filterPageByRules } from '../use-collection-manager'; +import * as styles from './edit-collection.css'; + +export interface EditCollectionModalProps { + init?: Collection; + title?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (view: Collection) => Promise; + allPageListConfig: AllPageListConfig; +} + +export const EditCollectionModal = ({ + init, + onConfirm, + open, + onOpenChange, + title, + allPageListConfig, +}: EditCollectionModalProps) => { + const t = useAFFiNEI18N(); + const onConfirmOnCollection = useCallback( + (view: Collection) => { + onConfirm(view) + .then(() => { + onOpenChange(false); + }) + .catch(err => { + console.error(err); + }); + }, + [onConfirm, onOpenChange] + ); + const onCancel = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + return ( + + {init ? ( + + ) : null} + + ); +}; + +export interface EditCollectionProps { + title?: string; + onConfirmText?: string; + init: Collection; + onCancel: () => void; + onConfirm: (collection: Collection) => void; + allPageListConfig: AllPageListConfig; +} + +export const EditCollection = ({ + init, + onConfirm, + onCancel, + onConfirmText, + allPageListConfig, +}: EditCollectionProps) => { + const t = useAFFiNEI18N(); + const [value, onChange] = useState(init); + const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]); + const onSaveCollection = useCallback(() => { + if (!isNameEmpty) { + onConfirm(value); + } + }, [value, isNameEmpty, onConfirm]); + const reset = useCallback(() => { + onChange({ + ...value, + filterList: init.filterList, + allowList: init.allowList, + }); + }, [init.allowList, init.filterList, value]); + const buttons = useMemo( + () => ( + <> + + + + ), + [onCancel, t, isNameEmpty, onSaveCollection, onConfirmText] + ); + + return ( +
+ {value.mode === 'page' ? ( + + ) : ( + + )} +
+ ); +}; + +export type AllPageListConfig = { + allPages: PageMeta[]; + workspace: Workspace; + isEdgeless: (id: string) => boolean; + getPage: (id: string) => PageMeta | undefined; + favoriteRender: (page: PageMeta) => ReactNode; +}; +const RulesMode = ({ + collection, + updateCollection, + reset, + buttons, + allPageListConfig, +}: { + collection: Collection; + updateCollection: (collection: Collection) => void; + reset: () => void; + buttons: ReactNode; + allPageListConfig: AllPageListConfig; +}) => { + const t = useAFFiNEI18N(); + const [showPreview, setShowPreview] = useState(true); + const allowListPages: PageMeta[] = []; + const rulesPages: PageMeta[] = []; + const [showTips, setShowTips] = useState(false); + useEffect(() => { + setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips')); + }, []); + const hideTips = useCallback(() => { + setShowTips(false); + localStorage.setItem('hide-rules-mode-include-page-tips', 'true'); + }, []); + allPageListConfig.allPages.forEach(v => { + if (v.trash) { + return; + } + const result = filterPageByRules( + collection.filterList, + collection.allowList, + v + ); + if (result) { + if (collection.allowList.includes(v.id)) { + allowListPages.push(v); + } else { + rulesPages.push(v); + } + } + }); + const { node: selectPageNode, open } = useSelectPage({ allPageListConfig }); + const openSelectPage = useCallback(() => { + open(collection.allowList).then( + ids => { + updateCollection({ + ...collection, + allowList: ids, + }); + }, + () => { + //do nothing + } + ); + }, [open, updateCollection, collection]); + const [expandInclude, setExpandInclude] = useState(false); + return ( + <> +
+ + Pages that meet the rules will be added to the current collection{' '} + highlight. + +
+
+
+
+ { + updateCollection({ + ...collection, + mode, + }); + }, + [collection, updateCollection] + )} + > + + {t['com.affine.editCollection.pages']()} + + + {t['com.affine.editCollection.rules']()} + + +
+
+
+ updateCollection({ ...collection, filterList }), + [collection, updateCollection] + )} + /> +
+
+ setExpandInclude(!expandInclude)} + className={styles.button} + width={24} + height={24} + style={{ + transform: expandInclude ? 'rotate(90deg)' : undefined, + }} + > +
+ include +
+
+
+ {collection.allowList.map(id => { + const page = allPageListConfig.allPages.find( + v => v.id === id + ); + return ( +
+
+
+ {allPageListConfig.isEdgeless(id) ? ( + + ) : ( + + )} + Page +
+
is
+
+ {page?.title || 'Untitled'} +
+
+ { + updateCollection({ + ...collection, + allowList: collection.allowList.filter( + v => v !== id + ), + }); + }} + > +
+ ); + })} +
+ +
+ Add include page +
+
+
+
+
+ {showTips ? ( +
+
+
HELP INFO
+ +
+
+ What is "Include"? +
+
+ "Include" refers to manually adding pages rather + than automatically adding them through rule matching. You can + manually add pages through the "Add pages" option or + by dragging and dropping (coming soon). +
+
+ ) : null} +
+
+ + {rulesPages.length > 0 ? ( + + ) : null} + {allowListPages.length > 0 ? ( +
+
include
+ +
+ ) : null} +
+
+
+
+
{ + setShowPreview(!showPreview); + }} + > + Preview +
+
+ Reset +
+
+ After searching, there are currently{' '} + + {allowListPages.length + rulesPages.length} + {' '} + pages. +
+
+
{buttons}
+
+ {selectPageNode} + + ); +}; +const PagesMode = ({ + collection, + updateCollection, + buttons, + allPageListConfig, +}: { + collection: Collection; + updateCollection: (collection: Collection) => void; + buttons: ReactNode; + allPageListConfig: AllPageListConfig; +}) => { + const t = useAFFiNEI18N(); + const { + showFilter, + filters, + updateFilters, + clickFilter, + createFilter, + filteredList, + } = useFilter(allPageListConfig.allPages); + const { searchText, updateSearchText, searchedList } = + useSearch(filteredList); + const clearSelected = useCallback(() => { + updateCollection({ + ...collection, + pages: [], + }); + }, [collection, updateCollection]); + const pageOperationsRenderer = useCallback( + (page: PageMeta) => allPageListConfig.favoriteRender(page), + [allPageListConfig] + ); + return ( + <> + updateSearchText(e.target.value)} + className={styles.rulesTitle} + placeholder={t['com.affine.editCollection.search.placeholder']()} + > +
+
+
+ { + updateCollection({ + ...collection, + mode, + }); + }, + [collection, updateCollection] + )} + > + + {t['com.affine.editCollection.pages']()} + + + {t['com.affine.editCollection.rules']()} + + + {!showFilter && filters.length === 0 ? ( + + } + > +
+ +
+
+ ) : ( + + )} +
+ {showFilter ? ( +
+ +
+ ) : null} + {searchedList.length ? ( + + { + updateCollection({ + ...collection, + pages: ids, + }); + }} + pageOperationsRenderer={pageOperationsRenderer} + selectedPageIds={collection.pages} + isPreferredEdgeless={allPageListConfig.isEdgeless} + > + + ) : ( + + )} +
+
+
+
+
+ Selected + + {collection.pages.length} + +
+
+ {t['com.affine.editCollection.pages.clear']()} +
+
+
{buttons}
+
+ + ); +}; +const SelectPage = ({ + allPageListConfig, + init, + onConfirm, + onCancel, +}: { + allPageListConfig: AllPageListConfig; + init: string[]; + onConfirm: (pageIds: string[]) => void; + onCancel: () => void; +}) => { + const t = useAFFiNEI18N(); + const [value, onChange] = useState(init); + const confirm = useCallback(() => { + onConfirm(value); + }, [value, onConfirm]); + const clearSelected = useCallback(() => { + onChange([]); + }, []); + const { + clickFilter, + createFilter, + filters, + showFilter, + updateFilters, + filteredList, + } = useFilter(allPageListConfig.allPages); + const { searchText, updateSearchText, searchedList } = + useSearch(filteredList); + return ( +
+ updateSearchText(e.target.value)} + placeholder={t['com.affine.editCollection.search.placeholder']()} + > +
+
+
+ Add include page +
+ {!showFilter && filters.length === 0 ? ( + + } + > +
+ +
+
+ ) : ( + + )} +
+ {showFilter ? ( +
+ +
+ ) : null} + {searchedList.length ? ( + + + + ) : ( + + )} +
+
+
+
+ Selected + + {value.length} + +
+
+ {t['com.affine.editCollection.pages.clear']()} +
+
+
+ + +
+
+
+ ); +}; +const useSelectPage = ({ + allPageListConfig, +}: { + allPageListConfig: AllPageListConfig; +}) => { + const [value, onChange] = useState<{ + init: string[]; + onConfirm: (ids: string[]) => void; + }>(); + const close = useCallback(() => { + onChange(undefined); + }, []); + return { + node: ( + + {value ? ( + + ) : null} + + ), + open: (init: string[]): Promise => + new Promise(res => { + onChange({ + init, + onConfirm: list => { + close(); + res(list); + }, + }); + }), + }; +}; +const useFilter = (list: PageMeta[]) => { + const [filters, changeFilters] = useState([]); + const [showFilter, setShowFilter] = useState(false); + const clickFilter = useCallback( + (e: MouseEvent) => { + if (showFilter || filters.length !== 0) { + e.stopPropagation(); + e.preventDefault(); + setShowFilter(!showFilter); + } + }, + [filters.length, showFilter] + ); + const onCreateFilter = useCallback( + (filter: Filter) => { + changeFilters([...filters, filter]); + setShowFilter(true); + }, + [filters] + ); + return { + showFilter, + filters, + updateFilters: changeFilters, + clickFilter, + createFilter: onCreateFilter, + filteredList: list.filter(v => { + if (v.trash) { + return false; + } + return filterPageByRules(filters, [], v); + }), + }; +}; +const useSearch = (list: PageMeta[]) => { + const [value, onChange] = useState(''); + return { + searchText: value, + updateSearchText: onChange, + searchedList: value + ? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase())) + : list, + }; +}; +const EmptyList = ({ search }: { search?: string }) => { + return ( +
+ +
+ Empty +
+ {search ? ( +
+ No page titles contain{' '} + + {search} + +
+ ) : null} +
+ ); +}; diff --git a/packages/frontend/component/src/components/page-list/view/index.ts b/packages/frontend/component/src/components/page-list/view/index.ts index de2ed02221..98cd08ad3a 100644 --- a/packages/frontend/component/src/components/page-list/view/index.ts +++ b/packages/frontend/component/src/components/page-list/view/index.ts @@ -1,3 +1,7 @@ +export * from './affine-shape'; export * from './collection-bar'; export * from './collection-list'; +export * from './collection-operations'; export * from './create-collection'; +export * from './edit-collection'; +export * from './use-edit-collection'; diff --git a/packages/frontend/component/src/components/page-list/view/use-action.tsx b/packages/frontend/component/src/components/page-list/view/use-action.tsx index a2db0c77a4..14a104cc39 100644 --- a/packages/frontend/component/src/components/page-list/view/use-action.tsx +++ b/packages/frontend/component/src/components/page-list/view/use-action.tsx @@ -1,16 +1,9 @@ -import type { Collection } from '@affine/env/filter'; +import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - DeleteIcon, - FilterIcon, - PinedIcon, - PinIcon, - UnpinIcon, -} from '@blocksuite/icons'; +import { DeleteIcon, FilterIcon } from '@blocksuite/icons'; import { type ReactNode, useMemo } from 'react'; import type { useCollectionManager } from '../use-collection-manager'; -import * as styles from './collection-bar.css'; interface CollectionBarAction { icon: ReactNode; @@ -24,7 +17,9 @@ export const useActions = ({ collection, setting, openEdit, + info, }: { + info: DeleteCollectionInfo; collection: Collection; setting: ReturnType; openEdit: (open: Collection) => void; @@ -32,37 +27,6 @@ export const useActions = ({ const t = useAFFiNEI18N(); return useMemo(() => { return [ - { - icon: ( - <> - {collection.pinned ? ( - - ) : ( - - )} - {collection.pinned ? ( - - ) : ( - - )} - - ), - name: 'pin', - tooltip: collection.pinned - ? t['com.affine.collection-bar.action.tooltip.unpin']() - : t['com.affine.collection-bar.action.tooltip.pin'](), - className: styles.pin, - click: () => { - setting - .updateCollection({ - ...collection, - pinned: !collection.pinned, - }) - .catch(err => { - console.error(err); - }); - }, - }, { icon: , name: 'edit', @@ -76,11 +40,11 @@ export const useActions = ({ name: 'delete', tooltip: t['com.affine.collection-bar.action.tooltip.delete'](), click: () => { - setting.deleteCollection(collection.id).catch(err => { + setting.deleteCollection(info, collection.id).catch(err => { console.error(err); }); }, }, ]; - }, [collection, t, setting, openEdit]); + }, [info, collection, t, setting, openEdit]); }; diff --git a/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx new file mode 100644 index 0000000000..da331adfc0 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx @@ -0,0 +1,72 @@ +import { + type AllPageListConfig, + CreateCollectionModal, + EditCollectionModal, +} from '@affine/component/page-list'; +import type { Collection } from '@affine/env/filter'; +import { useCallback, useState } from 'react'; + +export const useEditCollection = (config: AllPageListConfig) => { + const [data, setData] = useState<{ + collection: Collection; + onConfirm: (collection: Collection) => Promise; + }>(); + const close = useCallback(() => setData(undefined), []); + + return { + node: data ? ( + + ) : null, + open: (collection: Collection): Promise => + new Promise(res => { + setData({ + collection, + onConfirm: async collection => { + res(collection); + }, + }); + }), + }; +}; + +export const useEditCollectionName = ({ + title, + showTips, +}: { + title: string; + showTips?: boolean; +}) => { + const [data, setData] = useState<{ + name: string; + onConfirm: (name: string) => Promise; + }>(); + const close = useCallback(() => setData(undefined), []); + + return { + node: data ? ( + + ) : null, + open: (name: string): Promise => + new Promise(res => { + setData({ + name, + onConfirm: async collection => { + res(collection); + }, + }); + }), + }; +}; diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index d63db2996f..3ce35948ff 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -6,6 +6,8 @@ export * from './ui/checkbox'; export * from './ui/empty'; export * from './ui/input'; export * from './ui/layout'; +export * from './ui/lottie/collections-icon'; +export * from './ui/lottie/delete-icon'; export * from './ui/menu'; export * from './ui/mui'; export * from './ui/popper'; diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index a6658fa9e6..66371ee5d9 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -289,8 +289,8 @@ affine-block-hub { button, input, select, -textarea, -[role='button'] { +textarea +/* [role='button'] */ { -webkit-app-region: no-drag; } diff --git a/packages/frontend/component/src/ui/button/dropdown.tsx b/packages/frontend/component/src/ui/button/dropdown-button.tsx similarity index 80% rename from packages/frontend/component/src/ui/button/dropdown.tsx rename to packages/frontend/component/src/ui/button/dropdown-button.tsx index 6051d4f395..c34a2c06d3 100644 --- a/packages/frontend/component/src/ui/button/dropdown.tsx +++ b/packages/frontend/component/src/ui/button/dropdown-button.tsx @@ -8,19 +8,25 @@ import { import * as styles from './styles.css'; type DropdownButtonProps = { + size?: 'small' | 'default'; onClickDropDown?: MouseEventHandler; } & ButtonHTMLAttributes; export const DropdownButton = forwardRef< HTMLButtonElement, DropdownButtonProps ->(({ onClickDropDown, children, ...props }, ref) => { +>(({ onClickDropDown, children, size = 'default', ...props }, ref) => { const handleClickDropDown: MouseEventHandler = e => { e.stopPropagation(); onClickDropDown?.(e); }; return ( -
} + bottomBorder /> ); } diff --git a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx new file mode 100644 index 0000000000..3d5e9ea92d --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx @@ -0,0 +1,63 @@ +import { toast } from '@affine/component'; +import { + type AllPageListConfig, + FavoriteTag, +} from '@affine/component/page-list'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { PageMeta } from '@blocksuite/store'; +import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useCallback, useMemo } from 'react'; + +import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; +import { useCurrentWorkspace } from '../current/use-current-workspace'; +import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; + +export const useAllPageListConfig = () => { + const [currentWorkspace] = useCurrentWorkspace(); + const workspace = currentWorkspace.blockSuiteWorkspace; + const pageMetas = useBlockSuitePageMeta(workspace); + const { isPreferredEdgeless } = usePageHelper(workspace); + const pageMap = useMemo( + () => Object.fromEntries(pageMetas.map(page => [page.id, page])), + [pageMetas] + ); + const { toggleFavorite } = useBlockSuiteMetaHelper( + currentWorkspace.blockSuiteWorkspace + ); + const t = useAFFiNEI18N(); + const onToggleFavoritePage = useCallback( + (page: PageMeta) => { + const status = page.favorite; + toggleFavorite(page.id); + toast( + status + ? t['com.affine.toastMessage.removedFavorites']() + : t['com.affine.toastMessage.addedFavorites']() + ); + }, + [t, toggleFavorite] + ); + return useMemo(() => { + return { + allPages: pageMetas, + isEdgeless: isPreferredEdgeless, + workspace: currentWorkspace.blockSuiteWorkspace, + getPage: id => pageMap[id], + favoriteRender: page => { + return ( + onToggleFavoritePage(page)} + active={!!page.favorite} + /> + ); + }, + }; + }, [ + currentWorkspace.blockSuiteWorkspace, + isPreferredEdgeless, + pageMetas, + pageMap, + onToggleFavoritePage, + ]); +}; diff --git a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts index 0cf61b6aee..53dd028296 100644 --- a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts @@ -8,6 +8,7 @@ import { useCallback } from 'react'; import { setPageModeAtom } from '../../atoms'; import { currentModeAtom } from '../../atoms/mode'; import type { BlockSuiteWorkspace } from '../../shared'; +import { getWorkspaceSetting } from '../../utils/workspace-setting'; import { useReferenceLinkHelper } from './use-reference-link-helper'; export function useBlockSuiteMetaHelper( @@ -82,8 +83,9 @@ export function useBlockSuiteMetaHelper( trashRelate: isRoot ? parentMeta?.id : undefined, }); setPageReadonly(pageId, true); + getWorkspaceSetting(blockSuiteWorkspace).deletePages([pageId]); }, - [getPageMeta, metas, setPageMeta, setPageReadonly] + [blockSuiteWorkspace, getPageMeta, metas, setPageMeta, setPageReadonly] ); const restoreFromTrash = useCallback( diff --git a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts new file mode 100644 index 0000000000..2a99a4a3cf --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts @@ -0,0 +1,11 @@ +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useSession } from 'next-auth/react'; +import { useMemo } from 'react'; + +export const useDeleteCollectionInfo = () => { + const user = useSession().data?.user; + return useMemo( + () => (user ? { userName: user.name ?? '', userId: user.id } : null), + [user] + ); +}; diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx index dc7fdc2329..886383aabd 100644 --- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx +++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx @@ -35,8 +35,8 @@ export function useRegisterBlocksuiteEditorCommands( const onClickDelete = useCallback(() => { setTrashModal({ open: true, - pageId: pageId, - pageTitle: pageMeta.title, + pageIds: [pageId], + pageTitles: [pageMeta.title], }); }, [pageId, pageMeta.title, setTrashModal]); diff --git a/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts b/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts index 27c4f7922e..e33dfcffab 100644 --- a/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts @@ -10,14 +10,16 @@ import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; export function useTrashModalHelper(blocksuiteWorkspace: Workspace) { const t = useAFFiNEI18N(); const [trashModal, setTrashModal] = useAtom(trashModalAtom); - const { pageId } = trashModal; + const { pageIds } = trashModal; const { removeToTrash } = useBlockSuiteMetaHelper(blocksuiteWorkspace); const handleOnConfirm = useCallback(() => { - removeToTrash(pageId); + pageIds.forEach(pageId => { + removeToTrash(pageId); + }); toast(t['com.affine.toastMessage.movedTrash']()); setTrashModal({ ...trashModal, open: false }); - }, [pageId, removeToTrash, setTrashModal, t, trashModal]); + }, [pageIds, removeToTrash, setTrashModal, t, trashModal]); return { trashModal, diff --git a/packages/frontend/core/src/hooks/use-navigate-helper.ts b/packages/frontend/core/src/hooks/use-navigate-helper.ts index d56e4d554b..0ef61cca97 100644 --- a/packages/frontend/core/src/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/hooks/use-navigate-helper.ts @@ -12,6 +12,7 @@ export enum RouteLogic { PUSH = 'push', } +// todo: add a name -> path helper in the results export function useNavigateHelper() { const location = useLocation(); const navigate = useNavigate(); @@ -28,6 +29,18 @@ export function useNavigateHelper() { }, [navigate] ); + const jumpToCollection = useCallback( + ( + workspaceId: string, + collectionId: string, + logic: RouteLogic = RouteLogic.PUSH + ) => { + return navigate(`/workspace/${workspaceId}/collection/${collectionId}`, { + replace: logic === RouteLogic.REPLACE, + }); + }, + [navigate] + ); const jumpToPublicWorkspacePage = useCallback( ( workspaceId: string, @@ -116,6 +129,7 @@ export function useNavigateHelper() { openPage, jumpToExpired, jumpToSignIn, + jumpToCollection, }), [ jumpTo404, @@ -126,6 +140,7 @@ export function useNavigateHelper() { jumpToSignIn, jumpToSubPath, openPage, + jumpToCollection, ] ); } diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index b35f95708d..58c4f5a15d 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -1,11 +1,12 @@ -import { Content, displayFlex } from '@affine/component'; import { AppSidebarFallback, appSidebarResizingAtom, } from '@affine/component/app-sidebar'; import { BlockHubWrapper } from '@affine/component/block-hub'; -import type { DraggableTitleCellData } from '@affine/component/page-list'; -import { StyledTitleLink } from '@affine/component/page-list'; +import { + type DraggableTitleCellData, + PageListDragOverlay, +} from '@affine/component/page-list'; import { MainContainer, ToolContainer, @@ -197,12 +198,9 @@ export const WorkspaceLayoutInner = ({ const resizing = useAtomValue(appSidebarResizingAtom); const sensors = useSensors( - // Delay 10ms after mousedown - // Otherwise clicks would be intercepted useSensor(MouseSensor, { activationConstraint: { - delay: 500, - tolerance: 10, + distance: 10, }, }) ); @@ -288,34 +286,18 @@ export const WorkspaceLayoutInner = ({ }; function PageListTitleCellDragOverlay() { - const { active } = useDndContext(); - + const { active, over } = useDndContext(); const renderChildren = useCallback( - ({ icon, pageTitle }: DraggableTitleCellData) => { + ({ pageTitle }: DraggableTitleCellData) => { return ( - - {icon} - - {pageTitle} - - + {pageTitle} ); }, - [] + [over] ); return ( - + {active ? renderChildren(active.data.current as DraggableTitleCellData) : null} diff --git a/packages/frontend/core/src/pages/workspace/all-page.css.ts b/packages/frontend/core/src/pages/workspace/all-page.css.ts new file mode 100644 index 0000000000..ab3fb948bc --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/all-page.css.ts @@ -0,0 +1,66 @@ +import { style } from '@vanilla-extract/css'; + +export const root = style({ + height: '100%', + width: '100%', + display: 'flex', + flexFlow: 'column', + background: 'var(--affine-background-primary-color)', +}); + +export const scrollContainer = style({ + flex: 1, + width: '100%', + paddingBottom: '32px', +}); + +export const allPagesHeader = style({ + padding: '48px 16px 20px 24px', + overflow: 'hidden', + display: 'flex', + justifyContent: 'space-between', + background: 'var(--affine-background-primary-color)', +}); + +export const allPagesHeaderTitle = style({ + fontSize: 'var(--affine-font-h-3)', + fontWeight: 500, + color: 'var(--affine-text-secondary-color)', + display: 'flex', + alignItems: 'center', + gap: 8, +}); + +export const titleIcon = style({ + color: 'var(--affine-icon-color)', + display: 'inline-flex', + alignItems: 'center', +}); + +export const titleCollectionName = style({ + color: 'var(--affine-text-primary-color)', +}); + +export const floatingToolbar = style({ + position: 'absolute', + bottom: 26, + width: '100%', + zIndex: 1, +}); + +export const toolbarSelectedNumber = style({ + color: 'var(--affine-text-secondary-color)', +}); + +export const headerCreateNewButton = style({ + transition: 'opacity 0.1s ease-in-out', +}); + +export const newPageButtonLabel = style({ + display: 'flex', + alignItems: 'center', +}); + +export const headerCreateNewButtonHidden = style({ + opacity: 0, +}); diff --git a/packages/frontend/core/src/pages/workspace/all-page.tsx b/packages/frontend/core/src/pages/workspace/all-page.tsx index cc6ef27d0d..b18e4b31f0 100644 --- a/packages/frontend/core/src/pages/workspace/all-page.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page.tsx @@ -1,16 +1,50 @@ -import { useCollectionManager } from '@affine/component/page-list'; -import { WorkspaceSubPath } from '@affine/env/workspace'; +import { toast } from '@affine/component'; +import { + currentCollectionAtom, + FloatingToolbar, + NewPageButton as PureNewPageButton, + OperationCell, + PageList, + type PageListHandle, + PageListScrollContainer, + useCollectionManager, +} from '@affine/component/page-list'; +import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; +import { + CloseIcon, + DeleteIcon, + PlusIcon, + ViewLayersIcon, +} from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; import { getCurrentStore } from '@toeverything/infra/atom'; -import { useCallback } from 'react'; +import clsx from 'clsx'; +import { + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { LoaderFunction } from 'react-router-dom'; import { redirect } from 'react-router-dom'; +import { NIL } from 'uuid'; -import { getUIAdapter } from '../../adapters/workspace'; +import { collectionsCRUDAtom } from '../../atoms/collections'; +import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; +import { WorkspaceHeader } from '../../components/workspace-header'; +import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper'; +import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; -import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { currentCollectionsAtom } from '../../utils/user-setting'; +import * as styles from './all-page.css'; +import { EmptyPageList } from './page-list-empty'; +import { useFilteredPageMetas } from './pages'; export const loader: LoaderFunction = async args => { const rootStore = getCurrentStore(); @@ -27,39 +61,274 @@ export const loader: LoaderFunction = async args => { return redirect(`/workspace/${workspace.id}/${page.id}`); } } + rootStore.set(currentCollectionAtom, NIL); return null; }; -export const AllPage = () => { - const { jumpToPage } = useNavigateHelper(); +const PageListHeader = () => { + const t = useAFFiNEI18N(); + const setting = useCollectionManager(collectionsCRUDAtom); + const title = useMemo(() => { + if (setting.isDefault) { + return t['com.affine.all-pages.header'](); + } + return ( + <> + {t['com.affine.collections.header']()} / +
+ +
+
+ {setting.currentCollection.name} +
+ + ); + }, [setting.currentCollection.name, setting.isDefault, t]); + + return ( +
+
{title}
+ {t['New Page']()} +
+ ); +}; + +const usePageOperationsRenderer = () => { const [currentWorkspace] = useCurrentWorkspace(); - const setting = useCollectionManager(currentCollectionsAtom); - const onClickPage = useCallback( - (pageId: string, newTab?: boolean) => { - assertExists(currentWorkspace); - if (newTab) { - window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank'); - } else { - jumpToPage(currentWorkspace.id, pageId); + const { setTrashModal } = useTrashModalHelper( + currentWorkspace.blockSuiteWorkspace + ); + const { toggleFavorite } = useBlockSuiteMetaHelper( + currentWorkspace.blockSuiteWorkspace + ); + const t = useAFFiNEI18N(); + const pageOperationsRenderer = useCallback( + (page: PageMeta) => { + const onDisablePublicSharing = () => { + toast('Successfully disabled', { + portal: document.body, + }); + }; + return ( + <> + + setTrashModal({ + open: true, + pageIds: [page.id], + pageTitles: [page.title], + }) + } + onToggleFavoritePage={() => { + const status = page.favorite; + toggleFavorite(page.id); + toast( + status + ? t['com.affine.toastMessage.removedFavorites']() + : t['com.affine.toastMessage.addedFavorites']() + ); + }} + /> + + ); + }, + [currentWorkspace.id, setTrashModal, t, toggleFavorite] + ); + + return pageOperationsRenderer; +}; + +const PageListFloatingToolbar = ({ + selectedIds, + onClose, +}: { + selectedIds: string[]; + onClose: () => void; +}) => { + const open = selectedIds.length > 0; + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + onClose(); } }, - [currentWorkspace, jumpToPage] + [onClose] ); - const { PageList, Header } = getUIAdapter(currentWorkspace.flavour); + const [currentWorkspace] = useCurrentWorkspace(); + const { setTrashModal } = useTrashModalHelper( + currentWorkspace.blockSuiteWorkspace + ); + const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const handleMultiDelete = useCallback(() => { + const pageNameMapping = Object.fromEntries( + pageMetas.map(meta => [meta.id, meta.title]) + ); + + const pageNames = selectedIds.map(id => pageNameMapping[id] ?? ''); + setTrashModal({ + open: true, + pageIds: selectedIds, + pageTitles: pageNames, + }); + }, [pageMetas, selectedIds, setTrashModal]); + return ( - <> -
+ + +
+ {{ count: selectedIds.length } as any} +
+ pages selected +
+
+ } /> + + } + type="danger" /> - - + + ); +}; + +const NewPageButton = ({ + className, + children, + size, +}: PropsWithChildren<{ + className?: string; + size?: 'small' | 'default'; +}>) => { + const [currentWorkspace] = useCurrentWorkspace(); + const { importFile, createEdgeless, createPage } = usePageHelper( + currentWorkspace.blockSuiteWorkspace + ); + return ( +
+ +
{children}
+
+
+ ); +}; + +// even though it is called all page, it is also being used for collection route as well +export const AllPage = () => { + const [currentWorkspace] = useCurrentWorkspace(); + const { isPreferredEdgeless } = usePageHelper( + currentWorkspace.blockSuiteWorkspace + ); + const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const pageOperationsRenderer = usePageOperationsRenderer(); + const filteredPageMetas = useFilteredPageMetas( + 'all', + pageMetas, + currentWorkspace.blockSuiteWorkspace + ); + const [selectedPageIds, setSelectedPageIds] = useState([]); + const pageListRef = useRef(null); + const containerRef = useRef(null); + const deselectAllAndToggleSelect = useCallback(() => { + setSelectedPageIds([]); + pageListRef.current?.toggleSelectable(); + }, []); + + // make sure selected id is in the filtered list + const filteredSelectedPageIds = useMemo(() => { + const ids = filteredPageMetas.map(page => page.id); + return selectedPageIds.filter(id => ids.includes(id)); + }, [filteredPageMetas, selectedPageIds]); + + const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false); + // when PageListScrollContainer scrolls above 40px, show the create new page button on header + useEffect(() => { + const container = containerRef.current; + if (container) { + const handleScroll = () => { + setTimeout(() => { + const scrollTop = container.scrollTop ?? 0; + setShowHeaderCreateNewPage(scrollTop > 40); + }); + }; + container.addEventListener('scroll', handleScroll); + return () => { + container.removeEventListener('scroll', handleScroll); + }; + } + return; + }, []); + + return ( +
+ {currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? ( + + + + } + /> + ) : null} + + + {filteredPageMetas.length > 0 ? ( + <> + + + + ) : ( + + )} + +
); }; diff --git a/packages/frontend/core/src/pages/workspace/collection.css.ts b/packages/frontend/core/src/pages/workspace/collection.css.ts new file mode 100644 index 0000000000..0585e2b75b --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/collection.css.ts @@ -0,0 +1,25 @@ +import { style } from '@vanilla-extract/css'; + +export const placeholderButton = style({ + padding: '8px 18px', + border: '1px solid var(--affine-border-color)', + borderRadius: 8, + display: 'flex', + alignItems: 'center', + gap: 4, + fontWeight: 600, + cursor: 'pointer', + fontSize: 15, + lineHeight: '24px', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, +}); +export const button = style({ + userSelect: 'none', + borderRadius: 4, + cursor: 'pointer', + ':hover': { + backgroundColor: 'var(--affine-hover-color)', + }, +}); diff --git a/packages/frontend/core/src/pages/workspace/collection.tsx b/packages/frontend/core/src/pages/workspace/collection.tsx new file mode 100644 index 0000000000..b91e029f95 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/collection.tsx @@ -0,0 +1,275 @@ +import { pushNotificationAtom } from '@affine/component/notification-center'; +import { + AffineShapeIcon, + currentCollectionAtom, + useCollectionManager, + useEditCollection, +} from '@affine/component/page-list'; +import type { Collection } from '@affine/env/filter'; +import { + CloseIcon, + FilterIcon, + PageIcon, + ViewLayersIcon, +} from '@blocksuite/icons'; +import { getCurrentStore } from '@toeverything/infra/atom'; +import { useAtomValue } from 'jotai'; +import { useSetAtom } from 'jotai'; +import { useCallback, useEffect, useState } from 'react'; +import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; + +import { + collectionsCRUDAtom, + pageCollectionBaseAtom, +} from '../../atoms/collections'; +import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config'; +import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; +import { useNavigateHelper } from '../../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../../shared'; +import { getWorkspaceSetting } from '../../utils/workspace-setting'; +import { AllPage } from './all-page'; +import * as styles from './collection.css'; + +export const loader: LoaderFunction = async args => { + const rootStore = getCurrentStore(); + if (!args.params.collectionId) { + return redirect('/404'); + } + rootStore.set(currentCollectionAtom, args.params.collectionId); + return null; +}; + +export const Component = function CollectionPage() { + const { collections, loading } = useAtomValue(pageCollectionBaseAtom); + const navigate = useNavigateHelper(); + const params = useParams(); + const [workspace] = useCurrentWorkspace(); + const collection = collections.find(v => v.id === params.collectionId); + const pushNotification = useSetAtom(pushNotificationAtom); + useEffect(() => { + if (!loading && !collection) { + navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); + const collection = getWorkspaceSetting( + workspace.blockSuiteWorkspace + ).collectionsTrash.find(v => v.collection.id === params.collectionId); + let text = 'Collection is not exist'; + if (collection) { + if (collection.userId) { + text = `${collection.collection.name} is deleted by ${collection.userName}`; + } else { + text = `${collection.collection.name} is deleted`; + } + } + pushNotification({ + type: 'error', + title: text, + }); + } + }, [ + collection, + loading, + navigate, + params.collectionId, + pushNotification, + workspace.blockSuiteWorkspace, + workspace.id, + ]); + if (loading) { + return null; + } + if (!collection) { + return null; + } + return isEmpty(collection) ? ( + + ) : ( + + ); +}; + +const Placeholder = ({ collection }: { collection: Collection }) => { + const { updateCollection } = useCollectionManager(collectionsCRUDAtom); + const { node, open } = useEditCollection(useAllPageListConfig()); + const openPageEdit = useCallback(() => { + open({ ...collection, mode: 'page' }).then(updateCollection); + }, [open, collection, updateCollection]); + const openRuleEdit = useCallback(() => { + open({ ...collection, mode: 'rule' }).then(updateCollection); + }, [collection, open, updateCollection]); + const [showTips, setShowTips] = useState(false); + useEffect(() => { + setShowTips(!localStorage.getItem('hide-empty-collection-help-info')); + }, []); + const hideTips = useCallback(() => { + setShowTips(false); + localStorage.setItem('hide-empty-collection-help-info', 'true'); + }, []); + return ( +
+
+
+ + All Collections +
/
+
+
+ {collection.name} +
+
+
+
+ +
+ Empty Collection +
+
+ Collection is a smart folder where you can manually add pages or + automatically add pages through rules. +
+
+
+ + Add Pages +
+
+ + Add Rules +
+
+
+ {showTips ? ( +
+
+
HELP INFO
+ +
+
+
+ Add pages: You can + freely select pages and add them to the collection. +
+
+ Add rules: Rules are + based on filtering. After adding rules, pages that meet the + requirements will be automatically added to the current + collection. +
+
+
+ ) : null} +
+ {node} +
+ ); +}; + +const isEmpty = (collection: Collection) => { + return ( + (collection.mode === 'page' && collection.pages.length === 0) || + (collection.mode === 'rule' && + collection.allowList.length === 0 && + collection.filterList.length === 0) + ); +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page.tsx index 78816010e7..8fa58fd1ff 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page.tsx @@ -23,11 +23,12 @@ import type { Map as YMap } from 'yjs'; import { getUIAdapter } from '../../adapters/workspace'; import { setPageModeAtom } from '../../atoms'; +import { collectionsCRUDAtom } from '../../atoms/collections'; import { currentModeAtom } from '../../atoms/mode'; +import { WorkspaceHeader } from '../../components/workspace-header'; import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { currentCollectionsAtom } from '../../utils/user-setting'; const DetailPageImpl = (): ReactElement => { const { openPage, jumpToSubPath } = useNavigateHelper(); @@ -36,7 +37,7 @@ const DetailPageImpl = (): ReactElement => { assertExists(currentWorkspace); assertExists(currentPageId); const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; - const collectionManager = useCollectionManager(currentCollectionsAtom); + const collectionManager = useCollectionManager(collectionsCRUDAtom); const mode = useAtomValue(currentModeAtom); const setPageMode = useSetAtom(setPageModeAtom); useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode); @@ -66,7 +67,6 @@ const DetailPageImpl = (): ReactElement => { }); const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => { jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL); - collectionManager.backToAll(); collectionManager.setTemporaryFilter([createTagFilter(tagId)]); }); return () => { @@ -86,10 +86,10 @@ const DetailPageImpl = (): ReactElement => { ] ); - const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour); + const { PageDetail } = getUIAdapter(currentWorkspace.flavour); return ( <> -
{ + const { createPage } = usePageHelper(blockSuiteWorkspace); + const t = useAFFiNEI18N(); + const onCreatePage = useCallback(() => { + createPage?.(); + }, [createPage]); + + const getEmptyDescription = () => { + if (type === 'all') { + const createNewPageButton = ( + + ); + if (environment.isDesktop) { + const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N'; + return ( + + Click on the {createNewPageButton} button Or press + {{ shortcut } as any} to + create your first page. + + ); + } + return ( + + Click on the + {createNewPageButton} + button to create your first page. + + ); + } + if (type === 'trash') { + return t['emptyTrash'](); + } + if (type === 'shared') { + return t['emptySharedPages'](); + } + return; + }; + + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/pages/workspace/pages.tsx b/packages/frontend/core/src/pages/workspace/pages.tsx new file mode 100644 index 0000000000..31edb9454e --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/pages.tsx @@ -0,0 +1,52 @@ +import { filterPage, useCollectionManager } from '@affine/component/page-list'; +import type { PageMeta } from '@blocksuite/store'; +import { useAtomValue } from 'jotai'; +import { useMemo } from 'react'; + +import { allPageModeSelectAtom } from '../../atoms'; +import { collectionsCRUDAtom } from '../../atoms/collections'; +import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; +import type { BlockSuiteWorkspace } from '../../shared'; + +export const useFilteredPageMetas = ( + route: 'all' | 'trash', + pageMetas: PageMeta[], + workspace: BlockSuiteWorkspace +) => { + const { isPreferredEdgeless } = usePageHelper(workspace); + const pageMode = useAtomValue(allPageModeSelectAtom); + const { currentCollection } = useCollectionManager(collectionsCRUDAtom); + + const filteredPageMetas = useMemo( + () => + pageMetas + .filter(pageMeta => { + if (pageMode === 'all') { + return true; + } + if (pageMode === 'edgeless') { + return isPreferredEdgeless(pageMeta.id); + } + if (pageMode === 'page') { + return !isPreferredEdgeless(pageMeta.id); + } + console.error('unknown filter mode', pageMeta, pageMode); + return true; + }) + .filter(pageMeta => { + if ( + (route === 'trash' && !pageMeta.trash) || + (route === 'all' && pageMeta.trash) + ) { + return false; + } + if (!currentCollection) { + return true; + } + return filterPage(currentCollection, pageMeta); + }), + [pageMetas, pageMode, isPreferredEdgeless, route, currentCollection] + ); + + return filteredPageMetas; +}; diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index 85d7d49604..1399f7f457 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -1,43 +1,91 @@ +import { toast } from '@affine/component'; +import { + PageList, + PageListScrollContainer, + TrashOperationCell, +} from '@affine/component/page-list'; import { WorkspaceSubPath } from '@affine/env/workspace'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; +import type { PageMeta } from '@blocksuite/store'; +import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useCallback } from 'react'; -import { getUIAdapter } from '../../adapters/workspace'; -import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list'; +import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; +import { WorkspaceHeader } from '../../components/workspace-header'; +import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; -import { useNavigateHelper } from '../../hooks/use-navigate-helper'; +import * as styles from './all-page.css'; +import { EmptyPageList } from './page-list-empty'; +import { useFilteredPageMetas } from './pages'; export const TrashPage = () => { - const { jumpToPage } = useNavigateHelper(); const [currentWorkspace] = useCurrentWorkspace(); - const onClickPage = useCallback( - (pageId: string, newTab?: boolean) => { - assertExists(currentWorkspace); - if (newTab) { - window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank'); - } else { - jumpToPage(currentWorkspace.id, pageId); - } - }, - [currentWorkspace, jumpToPage] - ); // todo(himself65): refactor to plugin const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; assertExists(blockSuiteWorkspace); - const { Header } = getUIAdapter(currentWorkspace.flavour); + const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const filteredPageMetas = useFilteredPageMetas( + 'trash', + pageMetas, + currentWorkspace.blockSuiteWorkspace + ); + const { restoreFromTrash, permanentlyDeletePage } = + useBlockSuiteMetaHelper(blockSuiteWorkspace); + const { isPreferredEdgeless } = usePageHelper( + currentWorkspace.blockSuiteWorkspace + ); + const t = useAFFiNEI18N(); + const pageOperationsRenderer = useCallback( + (page: PageMeta) => { + const onRestorePage = () => { + restoreFromTrash(page.id); + toast( + t['com.affine.toastMessage.restored']({ + title: page.title || 'Untitled', + }) + ); + }; + const onPermanentlyDeletePage = () => { + permanentlyDeletePage(page.id); + toast(t['com.affine.toastMessage.permanentlyDeleted']()); + }; + return ( + + ); + }, + [permanentlyDeletePage, restoreFromTrash, t] + ); return ( <> -
- +
+ + {filteredPageMetas.length > 0 ? ( + + ) : ( + + )} + +
); }; diff --git a/packages/frontend/core/src/router.ts b/packages/frontend/core/src/router.ts index b067d9d7e8..b48d35ba57 100644 --- a/packages/frontend/core/src/router.ts +++ b/packages/frontend/core/src/router.ts @@ -14,6 +14,10 @@ export const routes = [ path: 'all', lazy: () => import('./pages/workspace/all-page'), }, + { + path: 'collection/:collectionId', + lazy: () => import('./pages/workspace/collection'), + }, { path: 'trash', lazy: () => import('./pages/workspace/trash-page'), diff --git a/packages/frontend/core/src/utils/filter.ts b/packages/frontend/core/src/utils/filter.ts deleted file mode 100644 index c50acfe167..0000000000 --- a/packages/frontend/core/src/utils/filter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { filterByFilterList } from '@affine/component/page-list'; -import type { Collection } from '@affine/env/filter'; -import type { PageMeta } from '@blocksuite/store'; - -export const filterPage = (collection: Collection, page: PageMeta) => { - if (collection.excludeList?.includes(page.id)) { - return false; - } - if (collection.allowList?.includes(page.id)) { - return true; - } - return filterByFilterList(collection.filterList, { - 'Is Favourited': !!page.favorite, - Created: page.createDate, - Updated: page.updatedDate ?? page.createDate, - Tags: page.tags, - }); -}; diff --git a/packages/frontend/core/src/utils/user-setting.ts b/packages/frontend/core/src/utils/user-setting.ts index 7bda66e0c0..6493047649 100644 --- a/packages/frontend/core/src/utils/user-setting.ts +++ b/packages/frontend/core/src/utils/user-setting.ts @@ -1,208 +1,43 @@ -import type { CollectionsAtom } from '@affine/component/page-list'; import type { Collection } from '@affine/env/filter'; -import { DisposableGroup } from '@blocksuite/global/utils'; -import { currentWorkspaceAtom } from '@toeverything/infra/atom'; -import { type DBSchema, openDB } from 'idb'; -import { atom } from 'jotai'; -import { atomWithObservable } from 'jotai/utils'; +import type { Workspace } from '@blocksuite/store'; import { nanoid } from 'nanoid'; -import { Observable } from 'rxjs'; import type { Map as YMap } from 'yjs'; import { Doc as YDoc } from 'yjs'; +export class UserSetting { + constructor( + private workspace: Workspace, + private userId: string + ) {} -import { sessionAtom } from '../atoms/cloud-user'; - -export interface PageCollectionDBV1 extends DBSchema { - view: { - key: Collection['id']; - value: Collection; - }; -} - -export interface StorageCRUD { - get: (key: string) => Promise; - set: (key: string, value: Value) => Promise; - delete: (key: string) => Promise; - list: () => Promise; -} - -type Subscribe = () => void; - -const collectionDBAtom = atom( - openDB('page-view', 1, { - upgrade(database) { - database.createObjectStore('view', { - keyPath: 'id', - }); - }, - }) -); - -const callbackSet = new Set(); - -const localCollectionCRUDAtom = atom(get => ({ - get: async (key: string) => { - const db = await get(collectionDBAtom); - const t = db.transaction('view').objectStore('view'); - return (await t.get(key)) ?? null; - }, - set: async (key: string, value: Collection) => { - const db = await get(collectionDBAtom); - const t = db.transaction('view', 'readwrite').objectStore('view'); - await t.put(value); - callbackSet.forEach(cb => cb()); - return key; - }, - delete: async (key: string) => { - const db = await get(collectionDBAtom); - const t = db.transaction('view', 'readwrite').objectStore('view'); - callbackSet.forEach(cb => cb()); - await t.delete(key); - }, - list: async () => { - const db = await get(collectionDBAtom); - const t = db.transaction('view').objectStore('view'); - return t.getAllKeys(); - }, -})); - -const getCollections = async ( - storage: StorageCRUD -): Promise => { - return storage - .list() - .then(async keys => { - return await Promise.all(keys.map(key => storage.get(key))).then(v => - v.filter((v): v is Collection => v !== null) + get setting(): YDoc { + const rootDoc = this.workspace.doc; + const settingMap = rootDoc.getMap('settings') as YMap; + if (!settingMap.has(this.userId)) { + settingMap.set( + this.userId, + new YDoc({ + guid: nanoid(), + }) ); - }) - .catch(error => { - console.error('Failed to load collections', error); - return []; - }); -}; - -const pageCollectionBaseAtom = atomWithObservable(get => { - const currentWorkspacePromise = get(currentWorkspaceAtom); - const session = get(sessionAtom); - const localCRUD = get(localCollectionCRUDAtom); - const userId = session?.data?.user.id ?? null; - - const useLocalStorage = userId === null; - - return new Observable(subscriber => { - // initial value - subscriber.next([]); - if (useLocalStorage) { - const fn = () => { - getCollections(localCRUD).then(async collections => { - const workspaceId = (await currentWorkspacePromise).id; - subscriber.next( - collections.filter(c => c.workspaceId === workspaceId) - ); - }); - }; - fn(); - callbackSet.add(fn); - return () => { - callbackSet.delete(fn); - }; - } else { - const group = new DisposableGroup(); - currentWorkspacePromise.then(async currentWorkspace => { - const collectionsFromLocal = await getCollections(localCRUD); - const rootDoc = currentWorkspace.doc; - const settingMap = rootDoc.getMap('settings') as YMap; - if (!settingMap.has(userId)) { - settingMap.set( - userId, - new YDoc({ - guid: nanoid(), - }) - ); - } - const settingDoc = settingMap.get(userId) as YDoc; - if (!settingDoc.isLoaded) { - settingDoc.load(); - await settingDoc.whenLoaded; - } - const viewMap = settingDoc.getMap('view') as YMap; - // sync local storage to doc - collectionsFromLocal.map(v => viewMap.set(v.id, v)); - // delete from indexeddb - Promise.all( - collectionsFromLocal.map(async v => { - await localCRUD.delete(v.id); - }) - ).catch(error => { - console.error('Failed to delete collections from indexeddb', error); - }); - const collectionsFromDoc: Collection[] = Array.from(viewMap.keys()) - .map(key => viewMap.get(key)) - .filter((v): v is Collection => !!v); - const collections = [...collectionsFromDoc]; - subscriber.next(collections); - if (group.disposed) { - return; - } - const fn = () => { - const collectionsFromDoc: Collection[] = Array.from(viewMap.keys()) - .map(key => viewMap.get(key)) - .filter((v): v is Collection => !!v); - const collections = [...collectionsFromLocal, ...collectionsFromDoc]; - subscriber.next(collections); - }; - viewMap.observe(fn); - group.add(() => { - viewMap.unobserve(fn); - }); - }); - return () => { - group.dispose(); - }; - } - }); -}); - -export const currentCollectionsAtom: CollectionsAtom = atom( - get => get(pageCollectionBaseAtom), - async (get, _, apply) => { - const collections = await get(pageCollectionBaseAtom); - let newCollections: Collection[]; - if (typeof apply === 'function') { - newCollections = apply(collections); - } else { - newCollections = apply; - } - const session = get(sessionAtom); - const userId = session?.data?.user.id ?? null; - const useLocalStorage = userId === null; - const added = newCollections.filter(v => !collections.includes(v)); - const removed = collections.filter(v => !newCollections.includes(v)); - if (useLocalStorage) { - const localCRUD = get(localCollectionCRUDAtom); - await Promise.all([ - ...added.map(async v => { - await localCRUD.set(v.id, v); - }), - ...removed.map(async v => { - await localCRUD.delete(v.id); - }), - ]); - } else { - const currentWorkspace = await get(currentWorkspaceAtom); - const rootDoc = currentWorkspace.doc; - const settingMap = rootDoc.getMap('settings') as YMap; - const settingDoc = settingMap.get(userId) as YDoc; - const viewMap = settingDoc.getMap('view') as YMap; - await Promise.all([ - ...added.map(async v => { - viewMap.set(v.id, v); - }), - ...removed.map(async v => { - viewMap.delete(v.id); - }), - ]); } + return settingMap.get(this.userId) as YDoc; } -); + + get loaded(): Promise { + if (!this.setting.isLoaded) { + this.setting.load(); + } + return this.setting.whenLoaded; + } + + /** + * @deprecated + */ + get view() { + return this.setting.getMap('view') as YMap; + } +} + +export const getUserSetting = (workspace: Workspace, userId: string) => { + return new UserSetting(workspace, userId); +}; diff --git a/packages/frontend/core/src/utils/workspace-setting.ts b/packages/frontend/core/src/utils/workspace-setting.ts new file mode 100644 index 0000000000..22aba87d3e --- /dev/null +++ b/packages/frontend/core/src/utils/workspace-setting.ts @@ -0,0 +1,125 @@ +import type { + Collection, + DeleteCollectionInfo, + DeletedCollection, +} from '@affine/env/filter'; +import type { Workspace } from '@blocksuite/store'; +import { Array as YArray } from 'yjs'; + +import { updateFirstOfYArray } from './yjs-utils'; + +const COLLECTIONS_KEY = 'collections'; +const COLLECTIONS_TRASH_KEY = 'collections_trash'; +const SETTING_KEY = 'setting'; + +export class WorkspaceSetting { + constructor(private workspace: Workspace) {} + + get doc() { + return this.workspace.doc; + } + + get setting() { + return this.workspace.doc.getMap(SETTING_KEY); + } + + get collectionsYArray() { + if (!this.setting.has(COLLECTIONS_KEY)) { + this.setting.set(COLLECTIONS_KEY, new YArray()); + } + return this.setting.get(COLLECTIONS_KEY) as YArray; + } + + get collectionsTrashYArray() { + if (!this.setting.has(COLLECTIONS_TRASH_KEY)) { + this.setting.set(COLLECTIONS_TRASH_KEY, new YArray()); + } + return this.setting.get(COLLECTIONS_TRASH_KEY) as YArray; + } + + get collections(): Collection[] { + return this.collectionsYArray.toArray() ?? []; + } + + get collectionsTrash(): DeletedCollection[] { + return this.collectionsTrashYArray.toArray() ?? []; + } + + updateCollection(id: string, updater: (value: Collection) => Collection) { + updateFirstOfYArray( + this.collectionsYArray, + v => v.id === id, + v => { + return updater(v); + } + ); + } + + addCollection(...collections: Collection[]) { + this.doc.transact(() => { + this.collectionsYArray.insert(0, collections); + }); + } + + deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) { + const set = new Set(ids); + this.workspace.doc.transact(() => { + const indexList: number[] = []; + const list: Collection[] = []; + this.collectionsYArray.forEach((collection, i) => { + if (set.has(collection.id)) { + set.delete(collection.id); + indexList.unshift(i); + list.push(JSON.parse(JSON.stringify(collection))); + } + }); + indexList.forEach(i => { + this.collectionsYArray.delete(i); + }); + this.collectionsTrashYArray.insert( + 0, + list.map(collection => ({ + userId: info?.userId, + userName: info ? info.userName : 'Local User', + collection, + })) + ); + if (this.collectionsTrashYArray.length > 10) { + this.collectionsTrashYArray.delete( + 10, + this.collectionsTrashYArray.length - 10 + ); + } + }); + } + + deletePagesFromCollection(collection: Collection, idSet: Set) { + const newAllowList = collection.allowList.filter(id => !idSet.has(id)); + const newPages = collection.pages.filter(id => !idSet.has(id)); + if ( + newAllowList.length !== collection.allowList.length || + newPages.length !== collection.pages.length + ) { + this.updateCollection(collection.id, old => { + return { + ...old, + allowList: newAllowList, + pages: newPages, + }; + }); + } + } + + deletePages(ids: string[]) { + const idSet = new Set(ids); + this.workspace.doc.transact(() => { + this.collections.forEach(collection => { + this.deletePagesFromCollection(collection, idSet); + }); + }); + } +} + +export const getWorkspaceSetting = (workspace: Workspace) => { + return new WorkspaceSetting(workspace); +}; diff --git a/packages/frontend/core/src/utils/yjs-utils.ts b/packages/frontend/core/src/utils/yjs-utils.ts new file mode 100644 index 0000000000..4cfb7ee71e --- /dev/null +++ b/packages/frontend/core/src/utils/yjs-utils.ts @@ -0,0 +1,18 @@ +import type { Array as YArray } from 'yjs'; + +export const updateFirstOfYArray = ( + array: YArray, + p: (value: T) => boolean, + update: (value: T) => T +) => { + array.doc?.transact(() => { + for (let i = 0; i < array.length; i++) { + const ele = array.get(i); + if (p(ele)) { + array.delete(i); + array.insert(i, [update(ele)]); + return; + } + } + }); +}; diff --git a/packages/frontend/hooks/src/use-block-suite-page-preview.ts b/packages/frontend/hooks/src/use-block-suite-page-preview.ts index 2aaff69393..ccb828582d 100644 --- a/packages/frontend/hooks/src/use-block-suite-page-preview.ts +++ b/packages/frontend/hooks/src/use-block-suite-page-preview.ts @@ -14,7 +14,7 @@ export const getPagePreviewText = (page: Page) => { .slice(0, 10) .map(block => block.text.toString()) .join('\n'); - return text.slice(0, 30); + return text.slice(0, 300); }; export function useBlockSuitePagePreview(page: Page): Atom { diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 45968ba57b..0036a2935f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -147,17 +147,24 @@ "com.affine.collection-bar.action.tooltip.unpin": "Unpin", "com.affine.collectionBar.backToAll": "Back to all", "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.", "com.affine.deleteLeaveWorkspace.leave": "Leave Workspace", "com.affine.deleteLeaveWorkspace.leaveDescription": "After you leave, you will not be able to access content within this workspace.", "com.affine.draw_with_a_blank_whiteboard": "Draw with a blank whiteboard", "com.affine.earlier": "Earlier", + "com.affine.editCollection.pages": "Pages", + "com.affine.editCollection.pages.clear": "Clear selected", + "com.affine.editCollection.search.placeholder": "Search page...", + "com.affine.editCollection.rules.tips": "Pages that meet the rules will be added to the current collection <2>{{highlight}}", + "com.affine.editCollection.rules.tips.highlight": "automatically", + "com.affine.editCollection.rules": "Rules", "com.affine.editCollection.button.cancel": "Cancel", "com.affine.editCollection.button.create": "Create", "com.affine.editCollection.filters": "Filters", "com.affine.editCollection.save": "Save", "com.affine.editCollection.saveCollection": "Save as New Collection", + "com.affine.editCollection.createCollection": "Create Collection", + "com.affine.editCollection.renameCollection": "Rename Collection", "com.affine.editCollection.untitledCollection": "Untitled Collection", "com.affine.editCollection.updateCollection": "Update Collection", "com.affine.editorModeSwitch.tooltip": "Switch", @@ -242,9 +249,12 @@ "com.affine.lastMonth": "Last month", "com.affine.lastWeek": "Last week", "com.affine.lastYear": "Last year", + "com.affine.moreThan30Days": "Older than a month", "com.affine.loading": "Loading...", "com.affine.moveToTrash.confirmModal.description": "{{title}} will be moved to Trash", "com.affine.moveToTrash.confirmModal.title": "Delete page?", + "com.affine.moveToTrash.confirmModal.title.multiple": "Delete {{ number }} pages?", + "com.affine.moveToTrash.confirmModal.description.multiple": "{{ number }} pages will be moved to Trash", "com.affine.moveToTrash.title": "Move to Trash", "com.affine.nameWorkspace.button.cancel": "Cancel", "com.affine.nameWorkspace.button.create": "Create", @@ -379,6 +389,11 @@ "com.affine.workspaceType.offline": "Available Offline", "com.affine.write_with_a_blank_page": "Write with a blank page", "com.affine.yesterday": "Yesterday", + "com.affine.all-pages.header": "All Pages", + "com.affine.collections.header": "Collections", + "com.affine.page.group-header.select-all": "Select All", + "com.affine.page.toolbar.selected_one": "<0>{{count}} page selected", + "com.affine.page.toolbar.selected_others": "<0>{{count}} page(s) selected", "Confirm": "Confirm", "Connector": "Connector", "Continue with Google": "Continue with Google", @@ -414,7 +429,8 @@ "Early Access Stage": "Early Access Stage", "Edgeless": "Edgeless", "Edit": "Edit", - "Edit Filter": "Edit Filter", + "com.affine.collection.menu.edit": "Edit Collection", + "com.affine.collection.menu.rename": "Rename", "emptyAllPages": "Click on the <1>$t(New Page) button to create your first page.", "emptyAllPagesClient": "Click on the <1>$t(New Page) button Or press <3>{{shortcut}} to create your first page.", "emptyFavorite": "Click Add to Favourites and the page will appear here.", @@ -556,6 +572,7 @@ "Update Available": "Update available", "Update workspace name success": "Update workspace name success", "Updated": "Updated", + "Actions": "Actions", "upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.", "Upload": "Upload", "Users": "Users", diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index 513d92aa62..b1be89ea1a 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -169,7 +169,6 @@ test.describe('collaboration', () => { const page2 = await context.newPage(); await loginUser(page2, user.email); await page2.goto(page.url()); - await waitForEditorLoad(page2); const collections = page2.getByTestId('collections'); await expect(collections.getByText('test collection')).toBeVisible(); } diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index 4f8d5bc49d..570c59d7c6 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -25,11 +25,9 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; function getAllPage(page: Page) { - const newPageButton = page - .locator('table') - .getByRole('button', { name: 'New Page' }); + const newPageButton = page.getByTestId('new-page-button'); const newPageDropdown = newPageButton.locator('svg'); - const edgelessBlockCard = page.getByTestId('new-edgeless-button-in-all-page'); + const edgelessBlockCard = page.getByTestId('switch-edgeless-mode-button'); async function clickNewPageButton() { return newPageButton.click(); @@ -91,7 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => { await clickNewPageButton(page); await clickSideBarAllPageButton(page); await waitForAllPagesLoad(page); - const pages = await page.locator('[data-testid="title"]').all(); + const pages = await page.locator('[data-testid="page-list-item"]').all(); const pageCount = pages.length; expect(pageCount).not.toBe(0); await createFirstFilter(page, 'Created'); @@ -123,7 +121,7 @@ test('creation of filters by created time, then click date picker to modify the await clickNewPageButton(page); await clickSideBarAllPageButton(page); await waitForAllPagesLoad(page); - const pages = await page.locator('[data-testid="title"]').all(); + const pages = await page.locator('[data-testid="page-list-item"]').all(); const pageCount = pages.length; expect(pageCount).not.toBe(0); await createFirstFilter(page, 'Created'); @@ -176,18 +174,19 @@ test('allow creation of filters by tags', async ({ page }) => { await waitForEditorLoad(page); await clickSideBarAllPageButton(page); await waitForAllPagesLoad(page); - const pages = await page.locator('[data-testid="title"]').all(); + const pages = await page.locator('[data-testid="page-list-item"]').all(); const pageCount = pages.length; expect(pageCount).not.toBe(0); await createFirstFilter(page, 'Tags'); await checkFilterName(page, 'is not empty'); - const pagesWithTags = await page.locator('[data-testid="title"]').all(); + const pagesWithTags = await page + .locator('[data-testid="page-list-item"]') + .all(); const pagesWithTagsCount = pagesWithTags.length; expect(pagesWithTagsCount).not.toBe(0); await createPageWithTag(page, { title: 'Page A', tags: ['A'] }); await createPageWithTag(page, { title: 'Page B', tags: ['B'] }); await clickSideBarAllPageButton(page); - await createFirstFilter(page, 'Tags'); await checkFilterName(page, 'is not empty'); await checkPagesCount(page, pagesWithTagsCount + 2); await changeFilter(page, 'contains all'); diff --git a/tests/affine-local/e2e/drag-page-to-trash-folder.spec.ts b/tests/affine-local/e2e/drag-page-to-trash-folder.spec.ts index b05eaa6450..d3cd38c7c8 100644 --- a/tests/affine-local/e2e/drag-page-to-trash-folder.spec.ts +++ b/tests/affine-local/e2e/drag-page-to-trash-folder.spec.ts @@ -1,6 +1,6 @@ import { test } from '@affine-test/kit/playwright'; import { openHomePage } from '@affine-test/kit/utils/load-page'; -import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; +import { dragTo, waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; test('drag a page from "All pages" list onto the "Trash" folder in the sidebar to move it to trash list', async ({ @@ -11,19 +11,17 @@ test('drag a page from "All pages" list onto the "Trash" folder in the sidebar t { await openHomePage(page); await waitForEditorLoad(page); - await page.getByText('All Pages').click(); + await page.getByTestId('app-sidebar').getByText('All Pages').click(); await page.waitForTimeout(500); } const title = 'AFFiNE - not just a note-taking app'; - // Drag-and-drop - // Ref: https://playwright.dev/docs/input#dragging-manually - await page.getByText(title).hover(); - await page.mouse.down(); - await page.waitForTimeout(1000); - await page.getByText('Trash').hover(); - await page.mouse.up(); + await dragTo( + page, + page.locator(`[role="button"]:has-text("${title}")`), + page.getByTestId('app-sidebar').getByText('Trash') + ); await expect( page.getByText('Successfully deleted'), 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 1cf834f4f2..089c27aec6 100644 --- a/tests/affine-local/e2e/local-first-collections-items.spec.ts +++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts @@ -1,8 +1,4 @@ import { test } from '@affine-test/kit/playwright'; -import { - checkDatePicker, - selectDateFromDatePicker, -} from '@affine-test/kit/utils/filter'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, @@ -29,16 +25,17 @@ const createAndPinCollection = async ( await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).fill('test page'); await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'test page', - }); + const cell = page.getByTestId('page-list-item-title').getByText('test page'); await expect(cell).toBeVisible(); await page.getByTestId('create-first-filter').click({ delay: 200, }); - await page.getByTestId(`filler-tag-Created`).click({ - delay: 200, - }); + await page + .getByTestId('variable-select') + .getByTestId(`filler-tag-Created`) + .click({ + delay: 200, + }); await page.getByTestId('save-as-collection').click({ delay: 200, }); @@ -47,8 +44,6 @@ const createAndPinCollection = async ( await title.fill(options?.collectionName ?? 'test collection'); await page.getByTestId('save-collection').click(); await page.waitForTimeout(100); - await page.getByTestId('collection-bar-option-pin').click(); - await page.waitForTimeout(100); }; test('Show collections items in sidebar', async ({ page }) => { @@ -93,32 +88,6 @@ test('Show collections items in sidebar', async ({ page }) => { await page.getByTestId('workspace-card').nth(0).click(); }); -test('pin and unpin collection', async ({ page }) => { - const name = 'asd'; - await createAndPinCollection(page, { collectionName: name }); - const collections = page.getByTestId('collections'); - const items = collections.getByTestId('collection-item'); - await page.waitForTimeout(50); - expect(await items.count()).toBe(1); - const first = items.first(); - await first.hover(); - await first.getByTestId('collection-options').click(); - const deleteCollection = page - .getByTestId('collection-option') - .getByText('Unpin'); - await deleteCollection.click(); - await page.waitForTimeout(50); - expect(await items.count()).toBe(0); - await page.getByTestId('collection-select').click(); - const option = page.locator('[data-testid=collection-select-option]', { - hasText: name, - }); - await option.hover(); - await option.getByTestId('collection-select-option-pin').click(); - await page.waitForTimeout(100); - expect(await items.count()).toBe(1); -}); - test('edit collection', async ({ page }) => { await createAndPinCollection(page); const collections = page.getByTestId('collections'); @@ -129,7 +98,7 @@ test('edit collection', async ({ page }) => { await first.getByTestId('collection-options').click(); const editCollection = page .getByTestId('collection-option') - .getByText('Edit Filter'); + .getByText('Rename'); await editCollection.click(); const title = page.getByTestId('input-collection-title'); await title.fill('123'); @@ -148,14 +117,10 @@ test('edit collection and change filter date', async ({ page }) => { await first.getByTestId('collection-options').click(); const editCollection = page .getByTestId('collection-option') - .getByText('Edit Filter'); + .getByText('Rename'); await editCollection.click(); const title = page.getByTestId('input-collection-title'); await title.fill('123'); - const today = new Date(); - await page.locator('[data-testid="filter-arg"]').locator('input').click(); - await selectDateFromDatePicker(page, today); - await checkDatePicker(page, today); await page.getByTestId('save-collection').click(); await page.waitForTimeout(100); expect(await first.textContent()).toBe('123'); @@ -173,15 +138,15 @@ test('create temporary filter by click tag', async ({ page }) => { await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); await page.locator('.tag', { hasText: 'TODO Tag' }).click(); - const cell = page.getByRole('cell', { - name: 'test page', - }); + const cell = page.getByTestId('page-list-item-title').getByText('test page'); await expect(cell).toBeVisible(); - expect(await page.getByTestId('title').count()).toBe(1); + expect(await page.getByTestId('page-list-item').count()).toBe(1); await page.getByTestId('filter-arg').click(); await page.getByTestId('multi-select-TODO Tag').click(); - expect(await page.getByTestId('title').count()).toBeGreaterThanOrEqual(2); + expect( + await page.getByTestId('page-list-item').count() + ).toBeGreaterThanOrEqual(2); }); test('add collection from sidebar', async ({ page }) => { @@ -191,9 +156,7 @@ test('add collection from sidebar', async ({ page }) => { await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).fill('test page'); await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'test page', - }); + const cell = page.getByTestId('page-list-item-title').getByText('test page'); await expect(cell).toBeVisible(); const nullCollection = page.getByTestId( 'slider-bar-collection-null-description' diff --git a/tests/affine-local/e2e/local-first-delete-page.spec.ts b/tests/affine-local/e2e/local-first-delete-page.spec.ts index a11e6f60a5..5e51692e71 100644 --- a/tests/affine-local/e2e/local-first-delete-page.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-page.spec.ts @@ -3,6 +3,8 @@ import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, getBlockSuiteEditorTitle, + getPageItem, + getPageOperationButton, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -22,22 +24,14 @@ test('page delete -> refresh page -> it should be disappear', async ({ name: 'this is a new page delete', }); expect(cell).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId).click(); const deleteBtn = page.getByTestId('move-to-trash'); await deleteBtn.click(); const confirmTip = page.getByText('Delete page?'); expect(confirmTip).not.toBeUndefined(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByTestId('trash-page').click(); - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .nth(1) - .click(); + await getPageItem(page, newPageId).getByTestId('delete-page-button').click(); await page.getByText('Delete permanently?').dblclick(); await page.getByRole('button', { name: 'Delete' }).click(); await page.reload(); @@ -65,21 +59,15 @@ test('page delete -> create new page -> refresh page -> new page should be appea name: 'this is a new page delete', }); expect(cellDelete).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageDeleteId) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageDeleteId).click(); const deleteBtn = page.getByTestId('move-to-trash'); await deleteBtn.click(); const confirmTip = page.getByText('Delete page?'); expect(confirmTip).not.toBeUndefined(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByTestId('trash-page').click(); - await page - .getByTestId('more-actions-' + newPageDeleteId) - .getByRole('button') - .nth(1) + await getPageItem(page, newPageDeleteId) + .getByTestId('delete-page-button') .click(); await page.getByText('Delete permanently?').dblclick(); await page.getByRole('button', { name: 'Delete' }).click(); @@ -100,9 +88,9 @@ test('page delete -> create new page -> refresh page -> new page should be appea const newPageId2 = page.url().split('/').reverse()[0]; await page.getByTestId('all-pages').click(); await page.reload(); - await page.getByTestId(`page-list-item-${newPageId1}`).click(); + await getPageItem(page, newPageId1).click(); await page.getByTestId('all-pages').click(); - await page.getByTestId(`page-list-item-${newPageId2}`).click(); + await getPageItem(page, newPageId2).click(); await page.getByTestId('all-pages').click(); const currentWorkspace = await workspace.current(); @@ -134,22 +122,14 @@ test('delete multiple pages -> create multiple pages -> refresh', async ({ name: 'this is a new page1', }); expect(cellDelete1).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageId1) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId1).click(); const deleteBtn1 = page.getByTestId('move-to-trash'); await deleteBtn1.click(); const confirmTip1 = page.getByText('Delete page?'); expect(confirmTip1).not.toBeUndefined(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByTestId('trash-page').click(); - await page - .getByTestId('more-actions-' + newPageId1) - .getByRole('button') - .nth(1) - .click(); + await getPageItem(page, newPageId1).getByTestId('delete-page-button').click(); await page.getByText('Delete permanently?').dblclick(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByTestId('all-pages').click(); @@ -159,22 +139,14 @@ test('delete multiple pages -> create multiple pages -> refresh', async ({ name: 'this is a new page2', }); expect(cellDelete2).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageId2) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId2).click(); const deleteBtn2 = page.getByTestId('move-to-trash'); await deleteBtn2.click(); const confirmTip2 = page.getByText('Delete page?'); expect(confirmTip2).not.toBeUndefined(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByTestId('trash-page').click(); - await page - .getByTestId('more-actions-' + newPageId2) - .getByRole('button') - .nth(1) - .click(); + await getPageItem(page, newPageId2).getByTestId('delete-page-button').click(); await page.getByText('Delete permanently?').dblclick(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByTestId('all-pages').click(); diff --git a/tests/affine-local/e2e/local-first-favorite-page.spec.ts b/tests/affine-local/e2e/local-first-favorite-page.spec.ts index 744fe2e064..eb03abf881 100644 --- a/tests/affine-local/e2e/local-first-favorite-page.spec.ts +++ b/tests/affine-local/e2e/local-first-favorite-page.spec.ts @@ -4,6 +4,7 @@ import { clickNewPageButton, clickPageMoreActions, getBlockSuiteEditorTitle, + getPageByTitle, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { waitForLogMessage } from '@affine-test/kit/utils/utils'; @@ -19,9 +20,9 @@ test('New a page and open it ,then favorite it', async ({ await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'this is a new page to favorite', - }); + const cell = page + .getByTestId('page-list-item') + .getByText('this is a new page to favorite'); expect(cell).not.toBeUndefined(); await cell.click(); @@ -85,9 +86,7 @@ test('Cancel favorite', async ({ page, workspace }) => { await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'this is a new page to favorite', - }); + const cell = getPageByTitle(page, 'this is a new page to favorite'); expect(cell).not.toBeUndefined(); await cell.click(); @@ -105,9 +104,10 @@ test('Cancel favorite', async ({ page, workspace }) => { await page.getByTestId('all-pages').click(); - const box = await page - .getByRole('cell', { name: 'this is a new page to favorite' }) - .boundingBox(); + const box = await getPageByTitle( + page, + 'this is a new page to favorite' + ).boundingBox(); //hover table record await page.mouse.move((box?.x ?? 0) + 10, (box?.y ?? 0) + 10); diff --git a/tests/affine-local/e2e/local-first-favorites-items.spec.ts b/tests/affine-local/e2e/local-first-favorites-items.spec.ts index 32a053ec69..769eb66841 100644 --- a/tests/affine-local/e2e/local-first-favorites-items.spec.ts +++ b/tests/affine-local/e2e/local-first-favorites-items.spec.ts @@ -5,6 +5,7 @@ import { clickPageMoreActions, createLinkedPage, getBlockSuiteEditorTitle, + getPageByTitle, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -17,9 +18,7 @@ test('Show favorite items in sidebar', async ({ page, workspace }) => { await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); const newPageId = page.url().split('/').reverse()[0]; await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'this is a new page to favorite', - }); + const cell = getPageByTitle(page, 'this is a new page to favorite'); await expect(cell).toBeVisible(); await cell.click(); await clickPageMoreActions(page); diff --git a/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts b/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts index 8049b2d6cc..177eeec166 100644 --- a/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts +++ b/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts @@ -3,6 +3,7 @@ import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, getBlockSuiteEditorTitle, + getPageOperationButton, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -18,11 +19,7 @@ test('click btn bew page and open in tab', async ({ page, workspace }) => { await page.getByTestId('all-pages').click(); - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId).click(); const [newTabPage] = await Promise.all([ page.waitForEvent('popup'), page.getByRole('menuitem', { name: 'Open in new tab' }).click(), diff --git a/tests/affine-local/e2e/local-first-restore-page.spec.ts b/tests/affine-local/e2e/local-first-restore-page.spec.ts index bfdf1e57e4..50da9fe814 100644 --- a/tests/affine-local/e2e/local-first-restore-page.spec.ts +++ b/tests/affine-local/e2e/local-first-restore-page.spec.ts @@ -3,6 +3,7 @@ import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, getBlockSuiteEditorTitle, + getPageOperationButton, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -23,11 +24,7 @@ test('New a page , then delete it in all pages, restore it', async ({ }); expect(cell).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId).click(); const deleteBtn = page.getByTestId('move-to-trash'); await deleteBtn.click(); const confirmTip = page.getByText('Delete page?'); @@ -39,11 +36,7 @@ test('New a page , then delete it in all pages, restore it', async ({ await page.waitForTimeout(50); const trashPage = page.url(); // restore it - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .first() - .click(); + await page.getByTestId('restore-page-button').click(); // stay in trash page expect(page.url()).toBe(trashPage); diff --git a/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts b/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts index fcc8a8fc08..5444504744 100644 --- a/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts +++ b/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts @@ -4,6 +4,7 @@ import { clickNewPageButton, clickPageMoreActions, getBlockSuiteEditorTitle, + getPageOperationButton, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -18,9 +19,9 @@ test('New a page ,then open it and show delete modal', async ({ await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).fill('this is a new page to delete'); await page.getByTestId('all-pages').click(); - const cell = page.getByRole('cell', { - name: 'this is a new page to delete', - }); + const cell = page + .getByTestId('page-list-item') + .getByText('this is a new page to delete'); expect(cell).not.toBeUndefined(); await cell.click(); @@ -50,11 +51,7 @@ test('New a page ,then go to all pages and show delete modal', async ({ }); expect(cell).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId).click(); const deleteBtn = page.getByTestId('move-to-trash'); await deleteBtn.click(); const confirmTip = page.getByText('Delete page?'); diff --git a/tests/affine-local/e2e/local-first-trash-page.spec.ts b/tests/affine-local/e2e/local-first-trash-page.spec.ts index dd2c2ef9e6..ba9248db61 100644 --- a/tests/affine-local/e2e/local-first-trash-page.spec.ts +++ b/tests/affine-local/e2e/local-first-trash-page.spec.ts @@ -4,6 +4,7 @@ import { clickNewPageButton, clickPageMoreActions, getBlockSuiteEditorTitle, + getPageOperationButton, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -24,11 +25,7 @@ test('New a page , then delete it in all pages, finally find it in trash', async }); expect(cell).not.toBeUndefined(); - await page - .getByTestId('more-actions-' + newPageId) - .getByRole('button') - .first() - .click(); + await getPageOperationButton(page, newPageId).click(); const deleteBtn = page.getByTestId('move-to-trash'); await deleteBtn.click(); const confirmTip = page.getByText('Delete page?'); diff --git a/tests/affine-local/e2e/local-first-workspace-list.spec.ts b/tests/affine-local/e2e/local-first-workspace-list.spec.ts index ae755df440..c63f4371e5 100644 --- a/tests/affine-local/e2e/local-first-workspace-list.spec.ts +++ b/tests/affine-local/e2e/local-first-workspace-list.spec.ts @@ -47,15 +47,15 @@ test('create one workspace in the workspace list', async ({ //check page list length await page.keyboard.press('Escape'); await clickSideBarAllPageButton(page); - await page.waitForTimeout(1000); + await page.waitForTimeout(2000); const pageList = page.locator('[data-testid=page-list-item]'); const result = await pageList.count(); - expect(result).toBe(0); + expect(result).toBe(13); await page.reload(); - await page.waitForTimeout(1000); + await page.waitForTimeout(4000); const pageList1 = page.locator('[data-testid=page-list-item]'); const result1 = await pageList1.count(); - expect(result1).toBe(0); + expect(result1).toBe(13); const currentWorkspace = await workspace.current(); expect(currentWorkspace.flavour).toContain('local'); diff --git a/tests/affine-local/e2e/quick-search.spec.ts b/tests/affine-local/e2e/quick-search.spec.ts index 306f306505..11f735f741 100644 --- a/tests/affine-local/e2e/quick-search.spec.ts +++ b/tests/affine-local/e2e/quick-search.spec.ts @@ -22,7 +22,6 @@ const keyboardDownAndSelect = async (page: Page, label: string) => { ) { await keyboardDownAndSelect(page, label); } else { - await page.pause(); await page.keyboard.press('Enter'); } }; diff --git a/tests/kit/utils/filter.ts b/tests/kit/utils/filter.ts index 66541d35d1..2762276a76 100644 --- a/tests/kit/utils/filter.ts +++ b/tests/kit/utils/filter.ts @@ -19,10 +19,7 @@ const monthNames = [ ]; export const createFirstFilter = async (page: Page, name: string) => { - await page - .locator('[data-testid="header"]') - .locator('button', { hasText: 'Filter' }) - .click(); + await page.locator('[data-testid="create-first-filter"]').click(); await page .locator('[data-testid="variable-select-item"]', { hasText: name }) .click(); @@ -42,9 +39,9 @@ const dateFormat = (date: Date) => { }; export const checkPagesCount = async (page: Page, count: number) => { - expect((await page.locator('[data-testid="title"]').all()).length).toBe( - count - ); + expect( + (await page.locator('[data-testid="page-list-item"]').all()).length + ).toBe(count); }; export const checkDatePicker = async (page: Page, date: Date) => { diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index 0ca791b5d1..927635dc45 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; export async function waitForEditorLoad(page: Page) { @@ -16,7 +16,7 @@ export async function waitForAllPagesLoad(page: Page) { export async function clickNewPageButton(page: Page) { // fixme(himself65): if too fast, the page will crash - await page.getByTestId('new-page-button').click({ + await page.getByTestId('new-page-button').first().click({ delay: 100, }); await waitForEditorLoad(page); @@ -51,3 +51,28 @@ export async function clickPageMoreActions(page: Page) { .getByTestId('header-dropDownButton') .click(); } + +export const getPageOperationButton = (page: Page, id: string) => { + return getPageItem(page, id).getByTestId('page-list-operation-button'); +}; + +export const getPageItem = (page: Page, id: string) => { + return page.locator(`[data-page-id="${id}"][data-testid="page-list-item"]`); +}; + +export const getPageByTitle = (page: Page, title: string) => { + return page.getByTestId('page-list-item').getByText(title); +}; + +export const dragTo = async (page: Page, locator: Locator, target: Locator) => { + await locator.hover(); + await page.mouse.down(); + await page.waitForTimeout(1000); + const targetElement = await target.boundingBox(); + if (!targetElement) { + throw new Error('target element not found'); + } + await page.mouse.move(targetElement.x, targetElement.y); + await target.hover(); + await page.mouse.up(); +}; diff --git a/tests/storybook/src/stories/checkbox.stories.tsx b/tests/storybook/src/stories/checkbox.stories.tsx index 6336a509c3..3616cb6529 100644 --- a/tests/storybook/src/stories/checkbox.stories.tsx +++ b/tests/storybook/src/stories/checkbox.stories.tsx @@ -59,6 +59,6 @@ export const Basic: StoryFn = props => { Basic.args = { checked: true, disabled: false, - intermediate: false, + indeterminate: false, onChange: console.log, }; diff --git a/tests/storybook/src/stories/page-list.stories.tsx b/tests/storybook/src/stories/page-list.stories.tsx index cf7140c530..d84ba47d8a 100644 --- a/tests/storybook/src/stories/page-list.stories.tsx +++ b/tests/storybook/src/stories/page-list.stories.tsx @@ -1,22 +1,30 @@ -import { Empty, toast } from '@affine/component'; -import type { OperationCellProps } from '@affine/component/page-list'; +import { toast } from '@affine/component'; import { + FloatingToolbar, NewPageButton, OperationCell, + type OperationCellProps, PageList, - PageListTrashView, + PageListItem, + type PageListItemProps, + type PageListProps, + PageListScrollContainer, + PageTags, + type PageTagsProps, } from '@affine/component/page-list'; -import type { Collection } from '@affine/env/filter'; -import { PageIcon } from '@blocksuite/icons'; -import { expect } from '@storybook/jest'; +import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; +import { PageIcon, TagsIcon } from '@blocksuite/icons'; +import { Schema, Workspace } from '@blocksuite/store'; import type { Meta, StoryFn } from '@storybook/react'; import { userEvent } from '@storybook/testing-library'; -import { atom } from 'jotai'; +import { initEmptyPage } from '@toeverything/infra/blocksuite'; +import { useState } from 'react'; +import { withRouter } from 'storybook-addon-react-router-v6'; export default { title: 'AFFiNE/PageList', - component: PageList, parameters: { + layout: 'fullscreen', chromatic: { disableSnapshot: true }, }, } satisfies Meta; @@ -30,7 +38,6 @@ AffineOperationCell.args = { isPublic: true, onToggleFavoritePage: () => toast('Toggle favorite page'), onDisablePublicSharing: () => toast('Disable public sharing'), - onOpenPageInNewTab: () => toast('Open page in new tab'), onRemoveToTrash: () => toast('Remove to trash'), }; AffineOperationCell.play = async ({ canvasElement }) => { @@ -59,163 +66,235 @@ AffineNewPageButton.play = async ({ canvasElement }) => { await userEvent.click(dropdown); }; -export const AffineAllPageList: StoryFn = ({ ...props }) => ( - +const testTags = [ + { + color: 'red', + id: 'test-tag-id-0', + value: 'foo', + }, + { + color: 'pink', + id: 'test-tag-id-1', + value: 'bar', + }, + { + color: 'purple', + id: 'test-tag-id-2', + value: 'foobar', + }, + { + color: 'black', + id: 'test-tag-id-3', + value: 'affine', + }, + { + color: 'orange', + id: 'test-tag-id-4', + value: 'blocksuite', + }, + { + color: 'yellow', + id: 'test-tag-id-5', + value: 'toeverything', + }, + { + color: 'green', + id: 'test-tag-id-6', + value: 'toeverything', + }, + { + color: 'blue', + id: 'test-tag-id-7', + value: 'toeverything', + }, + { + color: 'indigo', + id: 'test-tag-id-8', + value: 'toeverything', + }, + { + color: 'teal', + id: 'test-tag-id-9', + value: 'toeverything', + }, + { + color: 'cyan', + id: 'test-tag-id-10', + value: 'toeverything', + }, + { + color: 'gray', + id: 'test-tag-id-11', + value: 'toeverything', + }, + { + color: 'red', + id: 'test-tag-id-12', + value: 'toeverything', + }, +]; + +export const ListItem: StoryFn = props => ( + ); -const baseAtom = atom([]); -AffineAllPageList.args = { - isPublicWorkspace: false, - onCreateNewPage: () => toast('Create new page'), - onCreateNewEdgeless: () => toast('Create new edgeless'), - onImportFile: () => toast('Import file'), - collectionsAtom: atom( - get => get(baseAtom), - async (_, set, update) => { - set(baseAtom, update); - } - ), - list: [ - { - pageId: '1', - favorite: false, - icon: , - isPublicPage: true, - title: 'Last Page', - tags: [], - preview: 'this is page preview', - createDate: new Date('2021-01-01'), - updatedDate: new Date('2023-08-15'), - bookmarkPage: () => toast('Bookmark page'), - onClickPage: () => toast('Click page'), - onDisablePublicSharing: () => toast('Disable public sharing'), - onOpenPageInNewTab: () => toast('Open page in new tab'), - removeToTrash: () => toast('Remove to trash'), - }, - { - pageId: '3', - favorite: false, - icon: , - isPublicPage: true, - title: - '1 Example Public Page with long title that will be truncated because it is too too long', - tags: [], - preview: - 'this is page preview and it is very long and will be truncated because it is too long and it is very long and will be truncated because it is too long', - createDate: new Date('2021-01-01'), - updatedDate: new Date('2021-01-02'), - bookmarkPage: () => toast('Bookmark page'), - onClickPage: () => toast('Click page'), - onDisablePublicSharing: () => toast('Disable public sharing'), - onOpenPageInNewTab: () => toast('Open page in new tab'), - removeToTrash: () => toast('Remove to trash'), - }, - { - pageId: '2', - favorite: true, - isPublicPage: false, - icon: , - title: '2 Favorited Page 2021', - tags: [], - createDate: new Date('2021-01-02'), - updatedDate: new Date('2021-01-01'), - bookmarkPage: () => toast('Bookmark page'), - onClickPage: () => toast('Click page'), - onDisablePublicSharing: () => toast('Disable public sharing'), - onOpenPageInNewTab: () => toast('Open page in new tab'), - removeToTrash: () => toast('Remove to trash'), - }, - { - pageId: '4', - favorite: false, - isPublicPage: false, - icon: , - title: 'page created in 2023-04-01', - tags: [], - createDate: new Date('2023-04-01'), - updatedDate: new Date('2023-04-01'), - bookmarkPage: () => toast('Bookmark page'), - onClickPage: () => toast('Click page'), - onDisablePublicSharing: () => toast('Disable public sharing'), - onOpenPageInNewTab: () => toast('Open page in new tab'), - removeToTrash: () => toast('Remove to trash'), - }, - ], +ListItem.args = { + pageId: 'test-page-id', + title: 'Test Page Title', + preview: + 'this is page preview and it is very long and will be truncated because it is too long and it is very long and will be truncated because it is too long', + icon: , + to: '/hello', + selectable: true, + createDate: new Date('2021-01-01'), + updatedDate: new Date('2023-08-15'), + draggable: true, + tags: testTags, + selected: true, }; -export const AffineEmptyAllPageList: StoryFn = ({ - ...props -}) => ; +ListItem.decorators = [withRouter]; -AffineEmptyAllPageList.args = { - isPublicWorkspace: false, - onCreateNewPage: () => toast('Create new page'), - onCreateNewEdgeless: () => toast('Create new edgeless'), - onImportFile: () => toast('Import file'), - list: [], - fallback: ( - - empty description, click{' '} - -
- } - /> - ), +export const ListItemTags: StoryFn = props => ( +
+
+ +
+
+); + +ListItemTags.args = { + tags: testTags, + hoverExpandDirection: 'left', + widthOnHover: 600, + maxItems: 5, }; -export const AffinePublicPageList: StoryFn = ({ - ...props -}) => ; -AffinePublicPageList.args = { - ...AffineAllPageList.args, - isPublicWorkspace: true, +export const PageListStory: StoryFn = (props, { loaded }) => { + return ( + + + + ); }; -export const AffineAllPageMobileList: StoryFn = ({ - ...props -}) => ; +PageListStory.args = { + groupBy: 'createDate', +}; -AffineAllPageMobileList.args = AffineAllPageList.args; -AffineAllPageMobileList.parameters = { - viewport: { - defaultViewport: 'mobile2', +PageListStory.argTypes = { + selectable: { + control: 'radio', + options: [true, 'toggle', false], + }, + hideHeader: { + type: 'boolean', }, }; -export const AffineTrashPageList: StoryFn = ({ - ...props -}) => ; +async function createAndInitPage( + workspace: Workspace, + title: string, + preview: string +) { + const page = workspace.createPage(); + await initEmptyPage(page, title); + page.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0); + return page; +} -AffineTrashPageList.args = { - list: [ - { - pageId: '1', - icon: , - title: 'Example Page', - preview: 'this is trash page preview', - createDate: new Date('2021-01-01'), - trashDate: new Date('2021-01-01'), - onClickPage: () => toast('Click page'), - onPermanentlyDeletePage: () => toast('Permanently delete page'), - onRestorePage: () => toast('Restore page'), - }, - { - pageId: '2', - icon: , - title: 'Example Page with long title that will be truncated', - createDate: new Date('2021-01-01'), - onClickPage: () => toast('Click page'), - onPermanentlyDeletePage: () => toast('Permanently delete page'), - onRestorePage: () => toast('Restore page'), - }, - ], +PageListStory.loaders = [ + async () => { + const schema = new Schema(); + schema.register(AffineSchemas).register(__unstableSchemas); + const workspace = new Workspace({ + id: 'test-workspace-id', + schema, + }); + + workspace.meta.setProperties({ + tags: { + options: structuredClone(testTags), + }, + }); + + const page1 = await createAndInitPage( + workspace, + 'This is page 1', + 'Hello World from page 1' + ); + const page2 = await createAndInitPage( + workspace, + 'This is page 2', + 'Hello World from page 2' + ); + const page3 = await createAndInitPage( + workspace, + 'This is page 3', + 'Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3' + ); + + await createAndInitPage( + workspace, + 'This is page 4', + 'Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3' + ); + + page1.meta.createDate = new Date('2021-01-01').getTime(); + page2.meta.createDate = page2.meta.createDate - 3600 * 1000 * 24; + page3.meta.createDate = page3.meta.createDate - 3600 * 1000 * 24 * 7; + + workspace.meta.pageMetas[3].tags = testTags.slice(0, 3).map(t => t.id); + workspace.meta.pageMetas[2].tags = testTags.slice(0, 12).map(t => t.id); + + return { + blockSuiteWorkspace: workspace, + pages: workspace.meta.pages, + }; + }, +]; + +export const FloatingToolbarStory: StoryFn = props => { + const [open, setOpen] = useState(false); + return ( +
+ + + 10 Selected + + } + label="Add Tags" + onClick={console.log} + /> + } + label="Add Tags" + onClick={console.log} + /> + +
+ ); }; diff --git a/yarn.lock b/yarn.lock index 6f5b71bc0b..e9bd6b86e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -214,9 +214,11 @@ __metadata: "@popperjs/core": "npm:^2.11.8" "@radix-ui/react-avatar": "npm:^1.0.4" "@radix-ui/react-collapsible": "npm:^1.0.3" + "@radix-ui/react-popover": "npm:^1.0.7" "@radix-ui/react-radio-group": "npm:^1.1.3" "@radix-ui/react-scroll-area": "npm:^1.0.5" "@radix-ui/react-toast": "npm:^1.1.5" + "@radix-ui/react-toolbar": "npm:^1.0.4" "@storybook/jest": "npm:^0.2.3" "@storybook/testing-library": "npm:^0.2.2" "@testing-library/react": "npm:^14.0.0" @@ -237,6 +239,7 @@ __metadata: jotai: "npm:^2.4.3" lit: "npm:^2.8.0" lodash: "npm:^4.17.21" + lodash-es: "npm:^4.17.21" lottie-react: "npm:^2.4.0" lottie-web: "npm:^5.12.2" nanoid: "npm:^5.0.1" @@ -9372,7 +9375,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-popover@npm:^1.0.6": +"@radix-ui/react-popover@npm:^1.0.6, @radix-ui/react-popover@npm:^1.0.7": version: 1.0.7 resolution: "@radix-ui/react-popover@npm:1.0.7" dependencies: