diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 2d31e9d20..61a4a09c0 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -29,6 +29,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.3", "@emotion/server": "^11.11.0", diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx index c5d83200a..4464e809c 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx @@ -35,5 +35,11 @@ export const useFavorite = (pageId: string) => { export const FavoriteButton = ({ pageId }: FavoriteButtonProps) => { const { favorite, toggleFavorite } = useFavorite(pageId); - return ; + return ( + + ); }; diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-item.css.ts b/packages/frontend/core/src/components/page-list/collections/collection-list-item.css.ts index edb3eabb2..8d1a82146 100644 --- a/packages/frontend/core/src/components/page-list/collections/collection-list-item.css.ts +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-item.css.ts @@ -21,20 +21,7 @@ export const root = style({ }, }, }); -export const dragOverlay = style({ - display: 'flex', - alignItems: 'center', - zIndex: 1001, - cursor: 'grabbing', - maxWidth: '360px', - transition: 'transform 0.2s', - willChange: 'transform', - selectors: { - '&[data-over=true]': { - transform: 'scale(0.8)', - }, - }, -}); + export const dragPageItemOverlay = style({ height: '54px', borderRadius: '10px', diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx b/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx index 8a1d8ead8..1dba9eaa6 100644 --- a/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx @@ -1,4 +1,5 @@ import { Checkbox } from '@affine/component'; +import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useDraggable } from '@dnd-kit/core'; import type { PropsWithChildren } from 'react'; @@ -97,16 +98,22 @@ export const CollectionListItem = (props: CollectionListItemProps) => { /> + ); - }, [props.icon, props.onSelectedChange, props.selectable, props.selected]); + }, [ + props.icon, + props.onSelectedChange, + props.selectable, + props.selected, + props.title, + ]); // TODO: use getDropItemId const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: 'collection-list-item-title-' + props.collectionId, + id: getDNDId('collection-list', 'collection', props.collectionId), data: { - pageId: props.collectionId, - pageTitle: collectionTitleElement, + preview: collectionTitleElement, } satisfies DraggableTitleCellData, disabled: !props.draggable, }); diff --git a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx index 90b243fae..7c3c6f85a 100644 --- a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx +++ b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx @@ -106,7 +106,7 @@ export const VirtualizedCollectionList = ({ { // TODO: use getDropItemId const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: 'page-list-item-title-' + props.pageId, + id: getDNDId('doc-list', 'doc', props.pageId), data: { - pageId: props.pageId, - pageTitle: pageTitleElement, + preview: pageTitleElement, } satisfies DraggableTitleCellData, disabled: !props.draggable, }); @@ -279,16 +279,3 @@ function PageListItemWrapper({ return
{children}
; } } - -export const PageListDragOverlay = ({ - children, - over, -}: PropsWithChildren<{ - over?: boolean; -}>) => { - return ( -
- {children} -
- ); -}; diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts index e3a09d33d..0d14a04fc 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts @@ -21,20 +21,6 @@ export const root = style({ }, }, }); -export const dragOverlay = style({ - display: 'flex', - alignItems: 'center', - zIndex: 1001, - cursor: 'grabbing', - maxWidth: '360px', - transition: 'transform 0.2s', - willChange: 'transform', - selectors: { - '&[data-over=true]': { - transform: 'scale(0.8)', - }, - }, -}); export const dragPageItemOverlay = style({ height: '54px', borderRadius: '10px', diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx index a1c2bea7b..64e0c4e80 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx @@ -1,4 +1,5 @@ import { Checkbox } from '@affine/component'; +import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useDraggable } from '@dnd-kit/core'; import type { PropsWithChildren } from 'react'; @@ -99,10 +100,9 @@ export const TagListItem = (props: TagListItemProps) => { // TODO: use getDropItemId const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: 'tag-list-item-title-' + props.tagId, + id: getDNDId('tag-list', 'tag', props.tagId), data: { - pageId: props.tagId, - pageTitle: tagTitleElement, + preview: tagTitleElement, } satisfies DraggableTitleCellData, disabled: !props.draggable, }); diff --git a/packages/frontend/core/src/components/page-list/types.ts b/packages/frontend/core/src/components/page-list/types.ts index 436d55d32..fa1969033 100644 --- a/packages/frontend/core/src/components/page-list/types.ts +++ b/packages/frontend/core/src/components/page-list/types.ts @@ -141,8 +141,7 @@ type MakeRecord = { export type MetaRecord = MakeRecord; export type DraggableTitleCellData = { - pageId: string; - pageTitle: ReactNode; + preview: ReactNode; }; export type HeaderColDef = { diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index d3942521c..09e0cd5fc 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -6,34 +6,49 @@ import { filterPage, stopPropagation, } from '@affine/core/components/page-list'; +import { + type DNDIdentifier, + getDNDId, + parseDNDId, + resolveDragEndIntent, +} from '@affine/core/hooks/affine/use-global-dnd-helper'; import { CollectionService } from '@affine/core/modules/collection'; import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons'; import type { DocCollection } from '@blocksuite/store'; -import { useDroppable } from '@dnd-kit/core'; +import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import * as Collapsible from '@radix-ui/react-collapsible'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo, useState } from 'react'; import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config'; -import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta'; import { Workbench } from '../../../../modules/workbench'; import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link'; import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar'; +import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; +import * as draggableMenuItemStyles from '../components/draggable-menu-item.css'; import type { CollectionsListProps } from '../index'; import { Page } from './page'; import * as styles from './styles.css'; +const animateLayoutChanges: AnimateLayoutChanges = ({ + isSorting, + wasDragging, +}) => (isSorting || wasDragging ? false : true); + export const CollectionSidebarNavItem = ({ collection, docCollection, className, + dndId, }: { collection: Collection; docCollection: DocCollection; + dndId: DNDIdentifier; className?: string; }) => { const pages = useBlockSuiteDocMeta(docCollection); @@ -42,37 +57,50 @@ export const CollectionSidebarNavItem = ({ const collectionService = useService(CollectionService); const favAdapter = useService(FavoriteItemsAdapter); const t = useAFFiNEI18N(); - const dragItemId = getDropItemId('collections', collection.id); const favourites = useLiveData(favAdapter.favorites$); const removeFromAllowList = useCallback( (id: string) => { - collectionService.updateCollection(collection.id, () => ({ - ...collection, - allowList: collection.allowList?.filter(v => v !== id), - })); - + collectionService.deletePageFromCollection(collection.id, id); toast(t['com.affine.collection.removePage.success']()); }, [collection, collectionService, t] ); - const { setNodeRef, isOver } = useDroppable({ - id: dragItemId, + const overlayPreview = useMemo(() => { + return ( + } title={collection.name} /> + ); + }, [collection.name]); + + const { + setNodeRef, + isDragging, + attributes, + listeners, + transform, + over, + active, + transition, + } = useSortable({ + id: dndId, data: { - addToCollection: (id: string) => { - if (collection.allowList.includes(id)) { - toast(t['com.affine.collection.addPage.alreadyExists']()); - return; - } else { - toast(t['com.affine.collection.addPage.success']()); - } - collectionService.addPageToCollection(collection.id, id); - }, + preview: overlayPreview, }, + animateLayoutChanges, }); + const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin'; + const dragOverIntent = resolveDragEndIntent(active, over); + + const style = { + transform: CSS.Translate.toString(transform), + transition: isSorting ? transition : undefined, + }; + + const isOver = over?.id === dndId && dragOverIntent === 'collection:add'; + const config = useAllPageListConfig(); const allPagesMeta = useMemo( () => Object.fromEntries(pages.map(v => [v.id, v])), @@ -113,12 +141,18 @@ export const CollectionSidebarNavItem = ({ return ( { return ( {collections.map(view => { + const dragItemId = getDNDId( + 'sidebar-collections', + 'collection', + view.id + ); + return ( ); })} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx index a8fca7e7c..10aaa2572 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx @@ -8,7 +8,10 @@ import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; import React, { useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag'; +import { + type DNDIdentifier, + getDNDId, +} from '../../../../hooks/affine/use-global-dnd-helper'; import { useNavigateHelper } from '../../../../hooks/use-navigate-helper'; import { MenuItem as CollectionItem } from '../../../app-sidebar'; import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; @@ -18,11 +21,13 @@ import * as styles from './styles.css'; export const Page = ({ page, + parentId, docCollection, allPageMeta, inAllowList, removeFromAllowList, }: { + parentId: DNDIdentifier; page: DocMeta; inAllowList: boolean; removeFromAllowList: (id: string) => void; @@ -38,7 +43,7 @@ export const Page = ({ const active = params.pageId === pageId; const pageRecord = useLiveData(useService(PageRecordList).record$(pageId)); const pageMode = useLiveData(pageRecord?.mode$); - const dragItemId = getDragItemId('collectionPage', pageId); + const dragItemId = getDNDId('collection-list', 'doc', pageId, parentId); const icon = useMemo(() => { return pageMode === 'edgeless' ? : ; @@ -56,15 +61,13 @@ export const Page = ({ const pageTitle = page.title || t['Untitled'](); const pageTitleElement = useMemo(() => { - return ; + return ; }, [icon, pageTitle]); const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: dragItemId, data: { - pageId, - pageTitle: pageTitleElement, - removeFromCollection: () => removeFromAllowList(pageId), + preview: pageTitleElement, }, }); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts index 839771539..0ed2dd8a5 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts @@ -102,6 +102,9 @@ export const collapsibleContent = style({ overflow: 'hidden', marginTop: '4px', selectors: { + '&[data-hidden="true"]': { + display: 'none', + }, '&[data-state="open"]': { animation: `${slideDown} 0.2s ease-in-out`, }, diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx index 16e0186e3..e82b41725 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx @@ -1,16 +1,16 @@ import * as styles from '../favorite/styles.css'; export const DragMenuItemOverlay = ({ - pageTitle, + title, icon, }: { icon: React.ReactNode; - pageTitle: React.ReactNode; + title: React.ReactNode; }) => { return (
{icon} - {pageTitle} + {title}
); }; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts new file mode 100644 index 000000000..47f4dee74 --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts @@ -0,0 +1,33 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const draggableMenuItem = style({ + selectors: { + '&[data-draggable=true]:before': { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + left: 0, + width: 4, + height: 4, + transition: 'height 0.2s, opacity 0.2s', + backgroundColor: cssVar('placeholderColor'), + borderRadius: '2px', + opacity: 0, + willChange: 'height, opacity', + }, + '&[data-draggable=true]:hover:before': { + height: 12, + opacity: 1, + }, + '&[data-draggable=true][data-dragging=true]': { + backgroundColor: cssVar('hoverColor'), + }, + '&[data-draggable=true][data-dragging=true]:before': { + height: 32, + width: 2, + opacity: 1, + }, + }, +}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx index 075ac83b3..691b3373b 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx @@ -14,7 +14,7 @@ export interface ReferencePageProps { docCollection: DocCollection; pageId: string; metaMapping: Record; - parentIds: Set; + parentIds?: Set; } export const ReferencePage = ({ @@ -44,7 +44,7 @@ export const ReferencePage = ({ const [collapsed, setCollapsed] = useState(true); const collapsible = referencesToShow.length > 0; - const nestedItem = parentIds.size > 0; + const nestedItem = parentIds && parentIds.size > 0; const untitled = !metaMapping[pageId]?.title; const pageTitle = metaMapping[pageId]?.title || t['Untitled'](); @@ -86,7 +86,7 @@ export const ReferencePage = ({ docCollection={docCollection} pageId={ref} metaMapping={metaMapping} - parentIds={new Set([...parentIds, pageId])} + parentIds={new Set([...(parentIds ?? []), pageId])} /> ); })} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx index 5e790c2aa..430a5474f 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx @@ -1,27 +1,34 @@ +import { CategoryDivider } from '@affine/core/components/app-sidebar'; +import { + getDNDId, + resolveDragEndIntent, +} from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { CollectionService } from '@affine/core/modules/collection'; import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import type { WorkspaceFavoriteItem } from '@affine/core/modules/workspace/properties/schema'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { DocMeta } from '@blocksuite/store'; -import { useDroppable } from '@dnd-kit/core'; +import { useDndContext, useDroppable } from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { useLiveData, useService } from '@toeverything/infra'; -import { useMemo } from 'react'; +import { Fragment, useCallback, useMemo } from 'react'; -import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; import { CollectionSidebarNavItem } from '../collections'; import type { FavoriteListProps } from '../index'; +import { AddFavouriteButton } from './add-favourite-button'; import EmptyItem from './empty-item'; -import { FavouritePage } from './favourite-page'; +import { FavouriteDocSidebarNavItem } from './favourite-nav-item'; import * as styles from './styles.css'; -const emptyPageIdSet = new Set(); - -export const FavoriteList = ({ - docCollection: workspace, -}: FavoriteListProps) => { +const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => { const metas = useBlockSuiteDocMeta(workspace); const favAdapter = useService(FavoriteItemsAdapter); const collections = useLiveData(useService(CollectionService).collections$); - const dropItemId = getDropItemId('favorites'); + const dropItemId = getDNDId('sidebar-pin', 'container', workspace.id); const docMetaMapping = useMemo( () => @@ -46,47 +53,83 @@ export const FavoriteList = ({ }) ); - const { setNodeRef, isOver } = useDroppable({ + // disable drop styles when dragging from the pin list + const { active } = useDndContext(); + + const { setNodeRef, over } = useDroppable({ id: dropItemId, }); + const intent = resolveDragEndIntent(active, over); + const shouldRenderDragOver = intent === 'pin:add'; + + const renderFavItem = useCallback( + (item: WorkspaceFavoriteItem) => { + if (item.type === 'collection') { + const collection = collections.find(c => c.id === item.id); + if (collection) { + const dragItemId = getDNDId( + 'sidebar-pin', + 'collection', + collection.id + ); + return ( + + ); + } + } else if (item.type === 'doc' && !docMetaMapping[item.id].trash) { + return ( + + ); + } + return null; + }, + [collections, docMetaMapping, workspace] + ); + + const t = useAFFiNEI18N(); + return (
+ + + {favourites.map(item => { - if (item.type === 'collection') { - const collection = collections.find(c => c.id === item.id); - if (collection) { - return ( - - ); - } - } else if (item.type === 'doc' && !docMetaMapping[item.id].trash) { - return ( - - ); - } - return null; + return {renderFavItem(item)}; })} {favourites.length === 0 && }
); }; +export const FavoriteList = ({ + docCollection: workspace, +}: FavoriteListProps) => { + const favAdapter = useService(FavoriteItemsAdapter); + const favourites = useLiveData(favAdapter.orderedFavorites$); + const sortItems = useMemo(() => { + return favourites.map(fav => getDNDId('sidebar-pin', fav.type, fav.id)); + }, [favourites]); + return ( + + + + ); +}; + export default FavoriteList; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx similarity index 67% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx rename to packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx index b03580b9f..c693a7c85 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx @@ -1,30 +1,40 @@ +import { + getDNDId, + parseDNDId, +} from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; -import { useDraggable } from '@dnd-kit/core'; +import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import * as Collapsible from '@radix-ui/react-collapsible'; import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag'; import { MenuLinkItem } from '../../../app-sidebar'; import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; +import * as draggableMenuItemStyles from '../components/draggable-menu-item.css'; import { PostfixItem } from '../components/postfix-item'; import type { ReferencePageProps } from '../components/reference-page'; import { ReferencePage } from '../components/reference-page'; import * as styles from './styles.css'; -export const FavouritePage = ({ +const animateLayoutChanges: AnimateLayoutChanges = ({ + isSorting, + wasDragging, +}) => (isSorting || wasDragging ? false : true); + +export const FavouriteDocSidebarNavItem = ({ docCollection: workspace, pageId, metaMapping, - parentIds, -}: ReferencePageProps) => { +}: ReferencePageProps & { + sortable?: boolean; +}) => { const t = useAFFiNEI18N(); const params = useParams(); - const active = params.pageId === pageId; - const dragItemId = getDragItemId('favouritePage', pageId); + const linkActive = params.pageId === pageId; const pageRecord = useLiveData(useService(PageRecordList).record$(pageId)); const pageMode = useLiveData(pageRecord?.mode$); @@ -43,43 +53,57 @@ export const FavouritePage = ({ const [collapsed, setCollapsed] = useState(true); const collapsible = referencesToShow.length > 0; - const nestedItem = parentIds.size > 0; const untitled = !metaMapping[pageId]?.title; const pageTitle = metaMapping[pageId]?.title || t['Untitled'](); - const pageTitleElement = useMemo(() => { - return ; + const overlayPreview = useMemo(() => { + return ; }, [icon, pageTitle]); - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + const dragItemId = getDNDId('sidebar-pin', 'doc', pageId); + + const { + setNodeRef, + isDragging, + attributes, + listeners, + transform, + transition, + active, + } = useSortable({ id: dragItemId, data: { - pageId, - pageTitle: pageTitleElement, + preview: overlayPreview, }, + animateLayoutChanges, }); + const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin'; + const style = { + transform: CSS.Translate.toString(transform), + transition: isSorting ? transition : undefined, + }; + return ( - - - @@ -233,7 +229,7 @@ export const RootAppSidebar = ({ } - active={trashActive} + active={trashActive || trashDroppable.isOver} path={paths.trash(currentWorkspaceId)} > diff --git a/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts b/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts new file mode 100644 index 000000000..e904a21e8 --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts @@ -0,0 +1,281 @@ +import { toast } from '@affine/component'; +import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; +import { CollectionService } from '@affine/core/modules/collection'; +import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { + Active, + DragEndEvent, + Over, + UniqueIdentifier, +} from '@dnd-kit/core'; +import { useLiveData, useService, Workspace } from '@toeverything/infra'; +import { useMemo } from 'react'; + +import { useDeleteCollectionInfo } from './use-delete-collection-info'; +import { useTrashModalHelper } from './use-trash-modal-helper'; + +export type DndWhere = + | 'sidebar-pin' + | 'sidebar-collections' + | 'sidebar-trash' + | 'doc-list' + | 'collection-list' + | 'tag-list'; + +export type DNDItemKind = 'container' | 'collection' | 'doc' | 'tag'; + +// where:kind:id +// we want to make the id something that can be used to identify the item +// +// Note, not all combinations are valid +type DNDItemIdentifier = `${DndWhere}:${DNDItemKind}:${string}`; +export type DNDIdentifier = + | `${DNDItemIdentifier}/${DNDItemIdentifier}` + | DNDItemIdentifier; + +export type DndItem = { + where: DndWhere; + kind: DNDItemKind; + itemId: string; + parent?: DndItem; // for now we only support one level of nesting +}; + +export function getDNDId( + where: DndWhere, + kind: DNDItemKind, + id: string, + parentId?: DNDIdentifier +): DNDIdentifier { + const itemId = `${where}:${kind}:${id}` as DNDItemIdentifier; + return parentId ? `${parentId}/${itemId}` : itemId; +} + +export function parseDNDId( + id: UniqueIdentifier | null | undefined +): DndItem | undefined { + if (typeof id !== 'string') return undefined; + const parts = id.split('/'); + if (parts.length === 1) { + const [where, kind, itemId] = id.split(':') as [ + DndWhere, + DNDItemKind, + string, + ]; + return where && kind && itemId + ? { + where, + kind, + itemId, + } + : undefined; + } else if (parts.length === 2) { + const item = parseDNDId(parts[1]); + const parent = parseDNDId(parts[0]); + if (!item || !parent) return undefined; + return { + ...item, + parent, + }; + } else { + throw new Error('Invalid DND ID'); + } +} + +export function resolveDragEndIntent( + active?: Active | null, + over?: Over | null +) { + const dragItem = parseDNDId(active?.id); + const dropItem = parseDNDId(over?.id); + + if (!dragItem) return null; + + // any doc item to trash + if ( + dropItem?.where === 'sidebar-trash' && + (dragItem.kind === 'doc' || dragItem.kind === 'collection') + ) { + return 'trash:move-to'; + } + + // add page to collection + if ( + dragItem.kind === 'doc' && + dragItem.where !== dropItem?.where && + dropItem?.kind === 'collection' + ) { + return 'collection:add'; + } + + // move a doc from one collection to another + if ( + dragItem.kind === 'doc' && + dragItem?.where === 'collection-list' && + dragItem.parent?.kind === 'collection' && + dropItem?.kind !== 'collection' + ) { + return 'collection:remove'; + } + + // move any doc/collection to sidebar pin + if ( + dragItem.where !== 'sidebar-pin' && + dropItem?.where === 'sidebar-pin' && + (dragItem.kind === 'doc' || dragItem.kind === 'collection') + ) { + return 'pin:add'; + } + + // from sidebar pin to sidebar pin (reorder) + if ( + dragItem.where === 'sidebar-pin' && + dropItem?.where === 'sidebar-pin' && + (dragItem.kind === 'doc' || dragItem.kind === 'collection') && + (dropItem.kind === 'doc' || dropItem.kind === 'collection') + ) { + return 'pin:reorder'; + } + + // from sidebar pin to outside (remove from favourites) + if ( + dragItem.where === 'sidebar-pin' && + dropItem?.where !== 'sidebar-pin' && + (dragItem.kind === 'doc' || dragItem.kind === 'collection') + ) { + return 'pin:remove'; + } + + return null; +} + +export type GlobalDragEndIntent = ReturnType; + +export const useGlobalDNDHelper = () => { + const t = useAFFiNEI18N(); + const currentWorkspace = useService(Workspace); + const favAdapter = useService(FavoriteItemsAdapter); + const workspace = currentWorkspace.docCollection; + const { setTrashModal } = useTrashModalHelper(workspace); + const { getDocMeta } = useDocMetaHelper(workspace); + const collectionService = useService(CollectionService); + const collections = useLiveData(collectionService.collections$); + const deleteInfo = useDeleteCollectionInfo(); + + return useMemo(() => { + return { + handleDragEnd: (e: DragEndEvent) => { + const intent = resolveDragEndIntent(e.active, e.over); + + const dragItem = parseDNDId(e.active.id); + const dropItem = parseDNDId(e.over?.id); + + switch (intent) { + case 'pin:remove': + if ( + dragItem && + favAdapter.isFavorite( + dragItem.itemId, + dragItem.kind as 'doc' | 'collection' + ) + ) { + favAdapter.remove( + dragItem.itemId, + dragItem.kind as 'doc' | 'collection' + ); + toast( + t['com.affine.cmdk.affine.editor.remove-from-favourites']() + ); + } + return; + + case 'pin:reorder': + if (dragItem && dropItem) { + const fromId = FavoriteItemsAdapter.getFavItemKey( + dragItem.itemId, + dragItem.kind as 'doc' | 'collection' + ); + const toId = FavoriteItemsAdapter.getFavItemKey( + dropItem.itemId, + dropItem.kind as 'doc' | 'collection' + ); + favAdapter.sorter.move(fromId, toId); + } + + return; + + case 'pin:add': + if ( + dragItem && + !favAdapter.isFavorite( + dragItem.itemId, + dragItem.kind as 'doc' | 'collection' + ) + ) { + favAdapter.set( + dragItem.itemId, + dragItem.kind as 'collection' | 'doc', + true + ); + toast(t['com.affine.cmdk.affine.editor.add-to-favourites']()); + } + return; + + case 'collection:add': + if (dragItem && dropItem) { + const pageId = dragItem.itemId; + const collectionId = dropItem.itemId; + const collection = collections.find(c => { + return c.id === collectionId; + }); + + if (collection?.allowList.includes(pageId)) { + toast(t['com.affine.collection.addPage.alreadyExists']()); + } else { + collectionService.addPageToCollection(collectionId, pageId); + toast(t['com.affine.collection.addPage.success']()); + } + } + return; + + case 'collection:remove': + if (dragItem) { + const pageId = dragItem.itemId; + const collId = dragItem.parent?.itemId; + if (collId) { + collectionService.deletePageFromCollection(collId, pageId); + toast(t['com.affine.collection.removePage.success']()); + } + } + return; + + case 'trash:move-to': + if (dragItem) { + const pageId = dragItem.itemId; + if (dragItem.kind === 'doc') { + const pageTitle = getDocMeta(pageId)?.title ?? t['Untitled'](); + setTrashModal({ + open: true, + pageIds: [pageId], + pageTitles: [pageTitle], + }); + } else { + collectionService.deleteCollection(deleteInfo, dragItem.itemId); + } + } + return; + default: + return; + } + }, + }; + }, [ + collectionService, + collections, + deleteInfo, + favAdapter, + getDocMeta, + setTrashModal, + t, + ]); +}; diff --git a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts deleted file mode 100644 index 654898e80..000000000 --- a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { toast } from '@affine/component'; -import type { DraggableTitleCellData } from '@affine/core/components/page-list'; -import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { FavoriteItemsAdapter } from '@affine/core/modules/workspace'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core'; -import { useService, Workspace } from '@toeverything/infra'; -import { useCallback } from 'react'; - -import { useTrashModalHelper } from './use-trash-modal-helper'; - -// Unique droppable IDs -export const DropPrefix = { - SidebarCollections: 'sidebar-collections-', - SidebarTrash: 'sidebar-trash', - SidebarFavorites: 'sidebar-favorites', -}; - -export const DragPrefix = { - PageListItem: 'page-list-item-title-', - FavouriteListItem: 'favourite-list-item-', - CollectionListItem: 'collection-list-item-', - CollectionListPageItem: 'collection-list-page-item-', -}; - -export function getDropItemId( - type: 'collections' | 'trash' | 'favorites', - id?: string -): string { - let prefix = ''; - switch (type) { - case 'collections': - prefix = DropPrefix.SidebarCollections; - break; - case 'trash': - prefix = DropPrefix.SidebarTrash; - break; - case 'favorites': - prefix = DropPrefix.SidebarFavorites; - break; - } - - return `${prefix}${id}`; -} - -export function getDragItemId( - type: 'collection' | 'page' | 'collectionPage' | 'favouritePage', - id: string -): string { - let prefix = ''; - switch (type) { - case 'collection': - prefix = DragPrefix.CollectionListItem; - break; - case 'page': - prefix = DragPrefix.PageListItem; - break; - case 'collectionPage': - prefix = DragPrefix.CollectionListPageItem; - break; - case 'favouritePage': - prefix = DragPrefix.FavouriteListItem; - break; - } - - return `${prefix}${id}`; -} - -export const useSidebarDrag = () => { - const t = useAFFiNEI18N(); - const currentWorkspace = useService(Workspace); - const favAdapter = useService(FavoriteItemsAdapter); - const workspace = currentWorkspace.docCollection; - const { setTrashModal } = useTrashModalHelper(workspace); - - const { getDocMeta } = useDocMetaHelper(workspace); - - const isDropArea = useCallback( - (id: UniqueIdentifier | undefined, prefix: string) => { - return typeof id === 'string' && id.startsWith(prefix); - }, - [] - ); - - const processDrag = useCallback( - (e: DragEndEvent, dropPrefix: string, action: (pageId: string) => void) => { - const validPrefixes = Object.values(DragPrefix); - const isActiveIdValid = validPrefixes.some(pref => - String(e.active.id).startsWith(pref) - ); - if (isDropArea(e.over?.id, dropPrefix) && isActiveIdValid) { - const { pageId } = e.active.data.current as DraggableTitleCellData; - action(pageId); - } - return; - }, - [isDropArea] - ); - - const processCollectionsDrag = useCallback( - (e: DragEndEvent) => - processDrag(e, DropPrefix.SidebarCollections, pageId => { - e.over?.data.current?.addToCollection?.(pageId); - }), - [processDrag] - ); - - const processMoveToTrashDrag = useCallback( - (e: DragEndEvent) => { - const { pageId } = e.active.data.current as DraggableTitleCellData; - const pageTitle = getDocMeta(pageId)?.title ?? t['Untitled'](); - processDrag(e, DropPrefix.SidebarTrash, pageId => { - setTrashModal({ - open: true, - pageIds: [pageId], - pageTitles: [pageTitle], - }); - }); - }, - [getDocMeta, processDrag, setTrashModal, t] - ); - - const processFavouritesDrag = useCallback( - (e: DragEndEvent) => { - const { pageId } = e.active.data.current as DraggableTitleCellData; - const isFavourited = favAdapter.isFavorite(pageId, 'doc'); - const isFavouriteDrag = String(e.over?.id).startsWith( - DropPrefix.SidebarFavorites - ); - if (isFavourited && isFavouriteDrag) { - return toast(t['com.affine.collection.addPage.alreadyExists']()); - } - processDrag(e, DropPrefix.SidebarFavorites, pageId => { - favAdapter.set(pageId, 'doc', true); - toast(t['com.affine.cmdk.affine.editor.add-to-favourites']()); - }); - }, - [processDrag, t, favAdapter] - ); - - const processRemoveDrag = useCallback( - (e: DragEndEvent) => { - if (e.over) { - return; - } - - if (String(e.active.id).startsWith(DragPrefix.FavouriteListItem)) { - const pageId = e.active.data.current?.pageId; - favAdapter.remove(pageId, 'doc'); - toast(t['com.affine.cmdk.affine.editor.remove-from-favourites']()); - return; - } - if (String(e.active.id).startsWith(DragPrefix.CollectionListPageItem)) { - return e.active.data.current?.removeFromCollection?.(); - } - }, - - [favAdapter, t] - ); - - return useCallback( - (e: DragEndEvent) => { - processCollectionsDrag(e); - processFavouritesDrag(e); - processMoveToTrashDrag(e); - processRemoveDrag(e); - }, - [ - processCollectionsDrag, - processFavouritesDrag, - processMoveToTrashDrag, - processRemoveDrag, - ] - ); -}; diff --git a/packages/frontend/core/src/layouts/styles.css.ts b/packages/frontend/core/src/layouts/styles.css.ts new file mode 100644 index 000000000..61da61f31 --- /dev/null +++ b/packages/frontend/core/src/layouts/styles.css.ts @@ -0,0 +1,19 @@ +import { style } from '@vanilla-extract/css'; + +export const dragOverlay = style({ + display: 'flex', + alignItems: 'center', + zIndex: 1001, + cursor: 'grabbing', + maxWidth: '360px', + transition: 'transform 0.2s, opacity 0.2s', + willChange: 'transform opacity', + selectors: { + '&[data-over-drop=true]': { + transform: 'scale(0.8)', + }, + '&[data-sorting=true]': { + opacity: 0, + }, + }, +}); diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 98e6b5215..addbdf252 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -4,7 +4,6 @@ import { DndContext, DragOverlay, MouseSensor, - pointerWithin, useDndContext, useSensor, useSensors, @@ -18,6 +17,7 @@ import { import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { PropsWithChildren, ReactNode } from 'react'; import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import { matchPath } from 'react-router-dom'; import { Map as YMap } from 'yjs'; @@ -30,12 +30,14 @@ import { } from '../components/app-sidebar'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; import type { DraggableTitleCellData } from '../components/page-list'; -import { PageListDragOverlay } from '../components/page-list'; import { RootAppSidebar } from '../components/root-app-sidebar'; import { MainContainer, WorkspaceFallback } from '../components/workspace'; import { WorkspaceUpgrade } from '../components/workspace-upgrade'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; -import { useSidebarDrag } from '../hooks/affine/use-sidebar-drag'; +import { + resolveDragEndIntent, + useGlobalDNDHelper, +} from '../hooks/affine/use-global-dnd-helper'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands'; import { Workbench } from '../modules/workbench'; @@ -45,6 +47,7 @@ import { } from '../providers/modal-provider'; import { SWRConfigProvider } from '../providers/swr-config-provider'; import { pathGenerator } from '../shared'; +import * as styles from './styles.css'; const CMDKQuickSearchModal = lazy(() => import('../components/pure/cmdk').then(module => ({ @@ -149,20 +152,14 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { }) ); - const handleDragEnd = useSidebarDrag(); - + const { handleDragEnd } = useGlobalDNDHelper(); const { appSettings } = useAppSettingHelper(); - const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade); return ( <> {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */} - + }> { - + @@ -199,26 +196,48 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { ); }; -function PageListTitleCellDragOverlay() { +function GlobalDragOverlay() { const { active, over } = useDndContext(); - const [content, setContent] = useState(); + const [preview, setPreview] = useState(); useEffect(() => { if (active) { const data = active.data.current as DraggableTitleCellData; - setContent(data.pageTitle); + setPreview(data.preview); } // do not update content since it may disappear because of virtual rendering // eslint-disable-next-line react-hooks/exhaustive-deps }, [active?.id]); - const renderChildren = useCallback(() => { - return {content}; - }, [content, over]); + const intent = resolveDragEndIntent(active, over); - return ( - - {active ? renderChildren() : null} - + const overDropZone = + intent === 'pin:add' || + intent === 'collection:add' || + intent === 'trash:move-to'; + + const accent = + intent === 'pin:remove' + ? 'warning' + : intent === 'trash:move-to' + ? 'error' + : 'normal'; + + const sorting = intent === 'pin:reorder'; + + return createPortal( + + {preview ? ( +
+ {preview} +
+ ) : null} +
, + document.body ); } diff --git a/packages/frontend/core/src/modules/collection/service.ts b/packages/frontend/core/src/modules/collection/service.ts index ec25e2eac..0302f1026 100644 --- a/packages/frontend/core/src/modules/collection/service.ts +++ b/packages/frontend/core/src/modules/collection/service.ts @@ -90,6 +90,15 @@ export class CollectionService { }); } + deletePageFromCollection(collectionId: string, pageId: string) { + this.updateCollection(collectionId, old => { + return { + ...old, + allowList: old.allowList?.filter(id => id !== pageId), + }; + }); + } + deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) { const collectionsYArray = this.collectionsYArray; if (!collectionsYArray) { diff --git a/packages/frontend/core/src/modules/workspace/properties/adapter.ts b/packages/frontend/core/src/modules/workspace/properties/adapter.ts index 998e940b6..844b795a8 100644 --- a/packages/frontend/core/src/modules/workspace/properties/adapter.ts +++ b/packages/frontend/core/src/modules/workspace/properties/adapter.ts @@ -195,7 +195,7 @@ export class FavoriteItemsAdapter { } getItemId(item: WorkspaceFavoriteItem) { - return item.id; + return FavoriteItemsAdapter.getFavItemKey(item.id, item.type); } getItemOrder(item: WorkspaceFavoriteItem) { diff --git a/tests/affine-local/e2e/drag-page.spec.ts b/tests/affine-local/e2e/drag-page.spec.ts index 4f8e329c0..aa16ec943 100644 --- a/tests/affine-local/e2e/drag-page.spec.ts +++ b/tests/affine-local/e2e/drag-page.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/prefer-dom-node-dataset */ import { test } from '@affine-test/kit/playwright'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { @@ -13,37 +14,58 @@ import { expect } from '@playwright/test'; const dragToFavourites = async ( page: Page, dragItem: Locator, - pageId: string + id: string, + type: 'page' | 'collection' = 'page' ) => { const favourites = page.getByTestId('favourites'); await dragTo(page, dragItem, favourites); - const favouritePage = page.getByTestId(`favourite-page-${pageId}`); - expect(favouritePage).not.toBeUndefined(); - return favouritePage; + if (type === 'collection') { + const collection = page + .getByTestId(`favourites`) + .locator(`[data-collection-id="${id}"]`); + await expect(collection).toBeVisible(); + return collection; + } else { + const favouritePage = page.getByTestId(`favourite-page-${id}`); + await expect(favouritePage).toBeVisible(); + return favouritePage; + } }; -const dragToCollection = async (page: Page, dragItem: Locator) => { +const createCollection = async (page: Page, name: string) => { await page.getByTestId('slider-bar-add-collection-button').click(); const input = page.getByTestId('input-collection-title'); await expect(input).toBeVisible(); - await input.fill('test collection'); + await input.fill(name); await page.getByTestId('save-collection').click(); - const collection = page.getByTestId('collection-item'); - expect(collection).not.toBeUndefined(); + const collection = page.locator( + `[data-testid=collection-item]:has-text("${name}")` + ); + await expect(collection).toBeVisible(); + return collection; +}; + +const createPage = async (page: Page, title: string) => { + await clickNewPageButton(page); + await getBlockSuiteEditorTitle(page).fill(title); +}; + +const dragToCollection = async (page: Page, dragItem: Locator) => { + const collection = await createCollection(page, 'test collection'); await clickSideBarAllPageButton(page); await dragTo(page, dragItem, collection); await page.waitForTimeout(500); await collection.getByTestId('fav-collapsed-button').click(); const collectionPage = page.getByTestId('collection-page'); - expect(collectionPage).not.toBeUndefined(); + await expect(collectionPage).toBeVisible(); return collectionPage; }; const dragToTrash = async (page: Page, title: string, dragItem: Locator) => { // drag to trash await dragTo(page, dragItem, page.getByTestId('trash-page')); - const confirmTip = page.getByText('Delete page?'); - expect(confirmTip).not.toBeUndefined(); + const confirmTip = page.getByText('Delete doc?'); + await expect(confirmTip).toBeVisible(); await page.getByRole('button', { name: 'Delete' }).click(); @@ -60,16 +82,17 @@ const dragToTrash = async (page: Page, title: string, dragItem: Locator) => { ).toHaveCount(1); }; +test.beforeEach(async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); +}); + test('drag a page from "All pages" list to favourites, then drag to trash', async ({ page, }) => { const title = 'this is a new page to drag'; - { - await openHomePage(page); - await waitForEditorLoad(page); - await clickNewPageButton(page); - await getBlockSuiteEditorTitle(page).fill(title); - } + await waitForEditorLoad(page); + await createPage(page, title); const pageId = page.url().split('/').reverse()[0]; await clickSideBarAllPageButton(page); await page.waitForTimeout(500); @@ -87,12 +110,8 @@ test('drag a page from "All pages" list to collections, then drag to trash', asy page, }) => { const title = 'this is a new page to drag'; - { - await openHomePage(page); - await waitForEditorLoad(page); - await clickNewPageButton(page); - await getBlockSuiteEditorTitle(page).fill(title); - } + await waitForEditorLoad(page); + await createPage(page, title); await clickSideBarAllPageButton(page); await page.waitForTimeout(500); @@ -106,12 +125,8 @@ test('drag a page from "All pages" list to collections, then drag to trash', asy test('drag a page from "All pages" list to trash', async ({ page }) => { const title = 'this is a new page to drag'; - { - await openHomePage(page); - await waitForEditorLoad(page); - await clickNewPageButton(page); - await getBlockSuiteEditorTitle(page).fill(title); - } + await createPage(page, title); + await clickSideBarAllPageButton(page); await page.waitForTimeout(500); @@ -124,12 +139,8 @@ test('drag a page from "All pages" list to trash', async ({ page }) => { test('drag a page from favourites to collection', async ({ page }) => { const title = 'this is a new page to drag'; - { - await openHomePage(page); - await waitForEditorLoad(page); - await clickNewPageButton(page); - await getBlockSuiteEditorTitle(page).fill(title); - } + await createPage(page, title); + const pageId = page.url().split('/').reverse()[0]; await clickSideBarAllPageButton(page); await page.waitForTimeout(500); @@ -144,3 +155,68 @@ test('drag a page from favourites to collection', async ({ page }) => { // drag to collections await dragToCollection(page, favouritePage); }); + +test('drag a collection to favourites', async ({ page }) => { + await clickSideBarAllPageButton(page); + await page.waitForTimeout(500); + const collection = await createCollection(page, 'test collection'); + const collectionId = (await collection.getAttribute( + 'data-collection-id' + )) as string; + await dragToFavourites(page, collection, collectionId, 'collection'); +}); + +test('items in favourites can be reordered by dragging', async ({ page }) => { + const title0 = 'this is a new page to drag'; + await createPage(page, title0); + await page.getByTestId('pin-button').click(); + + const title1 = 'this is another new page to drag'; + await createPage(page, title1); + await page.getByTestId('pin-button').click(); + + { + const collection = await createCollection(page, 'test collection'); + const collectionId = (await collection.getAttribute( + 'data-collection-id' + )) as string; + await dragToFavourites(page, collection, collectionId, 'collection'); + } + + // assert the order of the items in favourites + await expect( + page.getByTestId('favourites').locator('[data-draggable]') + ).toHaveCount(3); + + await expect( + page.getByTestId('favourites').locator('[data-draggable]').first() + ).toHaveText(title0); + + await expect( + page.getByTestId('favourites').locator('[data-draggable]').last() + ).toHaveText('test collection'); + + // drag the first item to the last + const firstItem = page + .getByTestId('favourites') + .locator('[data-draggable]') + .first(); + const lastItem = page + .getByTestId('favourites') + .locator('[data-draggable]') + .last(); + await dragTo(page, firstItem, lastItem); + + // now check the order again + await expect( + page.getByTestId('favourites').locator('[data-draggable]') + ).toHaveCount(3); + + await expect( + page.getByTestId('favourites').locator('[data-draggable]').first() + ).toHaveText(title1); + + await expect( + page.getByTestId('favourites').locator('[data-draggable]').last() + ).toHaveText(title0); +}); 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 60e70221d..6cf10f66a 100644 --- a/tests/affine-local/e2e/local-first-favorites-items.spec.ts +++ b/tests/affine-local/e2e/local-first-favorites-items.spec.ts @@ -144,8 +144,8 @@ test('Add new favorite page via sidebar', async ({ page }) => { // enter random page title await getBlockSuiteEditorTitle(page).fill('this is a new fav page'); // check if the page title is shown in the favorite list - const favItem = page.locator( - '[data-type=favourite-list-item] >> text=this is a new fav page' - ); + const favItem = page + .getByTestId('favourites') + .locator('[data-draggable] >> text=this is a new fav page'); await expect(favItem).toBeVisible(); }); diff --git a/yarn.lock b/yarn.lock index f213f0a4a..a0d06b83a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -337,6 +337,7 @@ __metadata: "@dnd-kit/core": "npm:^6.1.0" "@dnd-kit/modifiers": "npm:^7.0.0" "@dnd-kit/sortable": "npm:^8.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@emotion/cache": "npm:^11.11.0" "@emotion/react": "npm:^11.11.3" "@emotion/server": "npm:^11.11.0"