diff --git a/packages/frontend/component/src/components/app-sidebar/menu-item/index.css.ts b/packages/frontend/component/src/components/app-sidebar/menu-item/index.css.ts index ca7764d96..dd3e25c5a 100644 --- a/packages/frontend/component/src/components/app-sidebar/menu-item/index.css.ts +++ b/packages/frontend/component/src/components/app-sidebar/menu-item/index.css.ts @@ -18,6 +18,7 @@ export const root = style({ padding: '0 12px', fontSize: 'var(--affine-font-sm)', marginTop: '4px', + position: 'relative', selectors: { '&:hover': { background: 'var(--affine-hover-color)', @@ -40,7 +41,7 @@ export const root = style({ paddingLeft: '4px', paddingRight: '4px', }, - '&[data-type="collection-list-item"][data-collapsible="false"][data-active="true"],&[data-type="favorite-list-item"][data-collapsible="false"][data-active="true"], &[data-type="favorite-list-item"][data-collapsible="false"]:hover, &[data-type="collection-list-item"][data-collapsible="false"]:hover': + '&[data-type="collection-list-item"][data-collapsible="false"][data-active="true"],&[data-type="reference-page"][data-collapsible="false"][data-active="true"], &[data-type="reference-page"][data-collapsible="false"]:hover, &[data-type="collection-list-item"][data-collapsible="false"]:hover': { width: 'calc(100% + 8px)', transform: 'translateX(-8px)', @@ -61,11 +62,14 @@ export const content = style({ }); export const postfix = style({ - justifySelf: 'flex-end', + right: '4px', + position: 'absolute', opacity: 0, pointerEvents: 'none', selectors: { [`${root}:hover &`]: { + justifySelf: 'flex-end', + position: 'initial', opacity: 1, pointerEvents: 'all', }, 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 index 2fee2391d..82cd0b657 100644 --- 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 @@ -23,13 +23,9 @@ export const root = style({ 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', + cursor: 'grabbing', maxWidth: '360px', transition: 'transform 0.2s', willChange: 'transform', @@ -39,6 +35,16 @@ export const dragOverlay = style({ }, }, }); +export const dragPageItemOverlay = style({ + height: '54px', + borderRadius: '10px', + display: 'flex', + alignItems: 'center', + background: 'var(--affine-hover-color-filled)', + boxShadow: 'var(--affine-menu-shadow)', + maxWidth: '360px', + minWidth: '260px', +}); export const dndCell = style({ position: 'relative', 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 index d5f0babc4..ce55e32ac 100644 --- a/packages/frontend/component/src/components/page-list/page-list-item.tsx +++ b/packages/frontend/component/src/components/page-list/page-list-item.tsx @@ -121,7 +121,7 @@ const PageListOperationsCell = ({ export const PageListItem = (props: PageListItemProps) => { const pageTitleElement = useMemo(() => { return ( - <> +
{
- +
); }, [ props.icon, @@ -142,6 +142,7 @@ export const PageListItem = (props: PageListItemProps) => { props.title, ]); + // TODO: use getDropItemId const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: 'page-list-item-title-' + props.pageId, data: { 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 4e3cb1327..5c204c93f 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 @@ -102,6 +102,7 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { ? defaultCollection : collections.find(v => v.id === currentCollectionId) ?? defaultCollection; + return { currentCollection: currentCollection, savedCollections: collections, 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 index fc2564c81..cc495d273 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx @@ -22,12 +22,14 @@ export const CollectionOperations = ({ config, setting, info, + openRenameModal, children, }: PropsWithChildren<{ info: DeleteCollectionInfo; collection: Collection; config: AllPageListConfig; setting: ReturnType; + openRenameModal?: () => void; }>) => { const { open: openEditCollectionModal, node: editModal } = useEditCollection(config); @@ -36,7 +38,12 @@ export const CollectionOperations = ({ useEditCollectionName({ title: t['com.affine.editCollection.renameCollection'](), }); + const showEditName = useCallback(() => { + // use openRenameModal if it is in the sidebar collection list + if (openRenameModal) { + return openRenameModal(); + } openEditCollectionNameModal(collection.name) .then(name => { return setting.updateCollection({ ...collection, name }); @@ -44,7 +51,8 @@ export const CollectionOperations = ({ .catch(err => { console.error(err); }); - }, [openEditCollectionNameModal, collection, setting]); + }, [openRenameModal, openEditCollectionNameModal, collection, setting]); + const showEdit = useCallback(() => { openEditCollectionModal(collection) .then(collection => { @@ -54,6 +62,7 @@ export const CollectionOperations = ({ console.error(err); }); }, [setting, collection, openEditCollectionModal]); + const actions = useMemo< Array< | { diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx index f789467b8..6f6272b91 100644 --- a/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx @@ -52,6 +52,7 @@ export const EditCollectionModal = ({ .catch(err => { console.error(err); }); + onOpenChange(false); }, [onConfirm, onOpenChange] ); diff --git a/packages/frontend/component/src/components/rename-modal/index.tsx b/packages/frontend/component/src/components/rename-modal/index.tsx new file mode 100644 index 000000000..18e0b3013 --- /dev/null +++ b/packages/frontend/component/src/components/rename-modal/index.tsx @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; + +import Input from '../../ui/input'; +import { Menu } from '../../ui/menu'; + +export const RenameModal = ({ + onRename, + currentName, + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onRename: (newName: string) => void; + currentName: string; +}) => { + const [value, setValue] = useState(currentName); + const handleRename = useCallback(() => { + onRename(value); + onOpenChange(false); + }, [onOpenChange, onRename, value]); + return ( + + } + > +
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/input/input.tsx b/packages/frontend/component/src/ui/input/input.tsx index a64671ac2..227492e75 100644 --- a/packages/frontend/component/src/ui/input/input.tsx +++ b/packages/frontend/component/src/ui/input/input.tsx @@ -48,12 +48,19 @@ export const Input = forwardRef(function Input( endFix, onEnter, onKeyDown, + autoFocus, ...otherProps }: InputProps, ref: ForwardedRef ) { const [isFocus, setIsFocus] = useState(false); + const handleAutoFocus = useCallback((ref: HTMLInputElement | null) => { + if (ref) { + window.setTimeout(() => ref.focus(), 0); + } + }, []); + return (
(function Input( large: size === 'large', 'extra-large': size === 'extraLarge', })} - ref={ref} + ref={autoFocus ? handleAutoFocus : ref} disabled={disabled} style={inputStyle} onFocus={useCallback( diff --git a/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx b/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx index 682da9fa7..89be6bb55 100644 --- a/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx @@ -81,11 +81,7 @@ const NameWorkspaceContent = ({ {...props} > { - if (ref) { - window.setTimeout(() => ref.focus(), 0); - } - }} + autoFocus data-testid="create-workspace-input" onKeyDown={handleKeyDown} placeholder={t['com.affine.nameWorkspace.placeholder']()} diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx index 6632476fa..df8f26790 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx @@ -67,11 +67,7 @@ export const WorkspaceDeleteModal = ({ )}
{ - if (ref) { - window.setTimeout(() => ref.focus(), 0); - } - }} + autoFocus onChange={setDeleteStr} data-testid="delete-workspace-input" onEnter={handleOnEnter} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx index d0f3f7b46..66ace961b 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx @@ -1,6 +1,7 @@ import { toast } from '@affine/component'; import { WorkspaceSubPath } from '@affine/env/workspace'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; import { initEmptyPage } from '@toeverything/infra/blocksuite'; import { useAtomValue, useSetAtom } from 'jotai'; @@ -14,6 +15,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { const { openPage, jumpToSubPath } = useNavigateHelper(); const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); const pageSettings = useAtomValue(pageSettingsAtom); + const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace); const isPreferredEdgeless = useCallback( (pageId: string) => pageSettings[pageId]?.mode === 'edgeless', @@ -61,15 +63,43 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { showImportModal({ workspace: blockSuiteWorkspace, onSuccess }); }, [blockSuiteWorkspace, openPage, jumpToSubPath]); + const createLinkedPageAndOpen = useAsyncCallback( + async (pageId: string) => { + const page = createPageAndOpen(); + await page.load(); + const parentPage = blockSuiteWorkspace.getPage(pageId); + if (parentPage) { + await parentPage.load(); + const text = parentPage.Text.fromDelta([ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: page.id, + }, + }, + }, + ]); + const [frame] = parentPage.getBlockByFlavour('affine:note'); + frame && parentPage.addBlock('affine:paragraph', { text }, frame.id); + setPageMeta(page.id, {}); + } + }, + [blockSuiteWorkspace, createPageAndOpen, setPageMeta] + ); + return useMemo(() => { return { createPage: createPageAndOpen, createEdgeless: createEdgelessAndOpen, importFile: importFileAndOpen, isPreferredEdgeless: isPreferredEdgeless, + createLinkedPage: createLinkedPageAndOpen, }; }, [ createEdgelessAndOpen, + createLinkedPageAndOpen, createPageAndOpen, importFileAndOpen, isPreferredEdgeless, 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 01d168df0..cdf3a5622 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 @@ -7,38 +7,26 @@ import { useCollectionManager, useSavedCollections, } from '@affine/component/page-list'; +import { RenameModal } from '@affine/component/rename-modal'; import { Button, IconButton } from '@affine/component/ui/button'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons'; import type { PageMeta, Workspace } from '@blocksuite/store'; -import type { DragEndEvent } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core'; import * as Collapsible from '@radix-ui/react-collapsible'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { collectionsCRUDAtom } from '../../../../atoms/collections'; import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config'; +import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; import type { CollectionsListProps } from '../index'; import { Page } from './page'; import * as styles from './styles.css'; -const Collections_DROP_AREA_PREFIX = 'collections-'; -const isCollectionsDropArea = (id?: string | number) => { - return typeof id === 'string' && id.startsWith(Collections_DROP_AREA_PREFIX); -}; -export const processCollectionsDrag = (e: DragEndEvent) => { - if ( - isCollectionsDropArea(e.over?.id) && - String(e.active.id).startsWith('page-list-item-') - ) { - e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId); - } -}; - const CollectionRenderer = ({ collection, pages, @@ -51,10 +39,25 @@ const CollectionRenderer = ({ info: DeleteCollectionInfo; }) => { const [collapsed, setCollapsed] = useState(true); + const [open, setOpen] = useState(false); const setting = useCollectionManager(collectionsCRUDAtom); const t = useAFFiNEI18N(); + const dragItemId = getDropItemId('collections', collection.id); + + const removeFromAllowList = useAsyncCallback( + async (id: string) => { + await setting.updateCollection({ + ...collection, + allowList: collection.allowList?.filter(v => v !== id), + }); + + toast(t['com.affine.collection.removePage.success']()); + }, + [collection, setting, t] + ); + const { setNodeRef, isOver } = useDroppable({ - id: `${Collections_DROP_AREA_PREFIX}${collection.id}`, + id: dragItemId, data: { addToCollection: (id: string) => { if (collection.allowList.includes(id)) { @@ -69,6 +72,7 @@ const CollectionRenderer = ({ }, }, }); + const config = useAllPageListConfig(); const allPagesMeta = useMemo( () => Object.fromEntries(pages.map(v => [v.id, v])), @@ -78,60 +82,69 @@ const CollectionRenderer = ({ () => new Set(collection.allowList), [collection.allowList] ); - const removeFromAllowList = useAsyncCallback( - async (id: string) => { - await setting.updateCollection({ - ...collection, - allowList: collection.allowList?.filter(v => v !== id), - }); - }, - [collection, setting] - ); + const pagesToRender = pages.filter( page => filterPage(collection, page) && !page.trash ); const location = useLocation(); const currentPath = location.pathname.split('?')[0]; const path = `/workspace/${workspace.id}/collection/${collection.id}`; + + const onRename = useAsyncCallback( + async (name: string) => { + await setting.updateCollection({ + ...collection, + name, + }); + toast(t['com.affine.toastMessage.rename']()); + }, + [collection, setting, t] + ); + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + return ( - + } to={path} postfix={ -
+
+
} collapsed={pagesToRender.length > 0 ? collapsed : undefined} > -
-
{collection.name}
-
+ {collection.name}
diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx index 1a49849f4..f54cf4a32 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx @@ -1,3 +1,2 @@ export * from './collections-list'; export { Page } from './page'; -export { PageOperations } from './page'; 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 e50458dfd..bf6239301 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 @@ -1,120 +1,22 @@ import { MenuItem as CollectionItem } from '@affine/component/app-sidebar'; -import { IconButton } from '@affine/component/ui/button'; -import { - Menu, - MenuIcon, - MenuItem, - type MenuItemProps, -} from '@affine/component/ui/menu'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - DeleteIcon, - EdgelessIcon, - FilterMinusIcon, - MoreHorizontalIcon, - PageIcon, -} from '@blocksuite/icons'; +import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import type { PageMeta, Workspace } from '@blocksuite/store'; +import { useDraggable } from '@dnd-kit/core'; import * as Collapsible from '@radix-ui/react-collapsible'; import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references'; import { useAtomValue } from 'jotai/index'; -import type { ReactElement } from 'react'; import React, { useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { pageSettingFamily } from '../../../../atoms'; -import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper'; +import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag'; import { useNavigateHelper } from '../../../../hooks/use-navigate-helper'; +import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; +import { PostfixItem } from '../components/postfix-item'; import { ReferencePage } from '../components/reference-page'; import * as styles from './styles.css'; -export const PageOperations = ({ - page, - inAllowList, - removeFromAllowList, - workspace, -}: { - workspace: Workspace; - page: PageMeta; - inAllowList: boolean; - removeFromAllowList: (id: string) => void; -}) => { - const t = useAFFiNEI18N(); - const { setTrashModal } = useTrashModalHelper(workspace); - const onClickDelete = useCallback(() => { - setTrashModal({ - open: true, - pageIds: [page.id], - pageTitles: [page.title], - }); - }, [page.id, page.title, setTrashModal]); - const actions = useMemo< - Array< - | { - icon: ReactElement; - name: string; - click: () => void; - type?: MenuItemProps['type']; - element?: undefined; - } - | { - element: ReactElement; - } - > - >( - () => [ - ...(inAllowList - ? [ - { - icon: ( - - - - ), - name: t['Remove special filter'](), - click: () => removeFromAllowList(page.id), - }, - { - element: ( -
- ), - }, - ] - : []), - { - icon: ( - - - - ), - name: t['com.affine.trashOperation.delete'](), - click: onClickDelete, - type: 'danger', - }, - ], - [inAllowList, t, onClickDelete, removeFromAllowList, page.id] - ); - return ( - <> - {actions.map(action => { - if (action.element) { - return action.element; - } - return ( - - {action.name} - - ); - })} - - ); -}; export const Page = ({ page, workspace, @@ -130,19 +32,48 @@ export const Page = ({ }) => { const [collapsed, setCollapsed] = React.useState(true); const params = useParams(); - const { jumpToPage } = useNavigateHelper(); + const t = useAFFiNEI18N(); + const pageId = page.id; const active = params.pageId === pageId; const setting = useAtomValue(pageSettingFamily(pageId)); - const icon = setting?.mode === 'edgeless' ? : ; - const references = useBlockSuitePageReferences(workspace, pageId); + const dragItemId = getDragItemId('collectionPage', pageId); + + const icon = useMemo(() => { + return setting?.mode === 'edgeless' ? : ; + }, [setting?.mode]); + + const { jumpToPage } = useNavigateHelper(); const clickPage = useCallback(() => { jumpToPage(workspace.id, page.id); }, [jumpToPage, page.id, workspace.id]); - const referencesToRender = references.filter(id => !allPageMeta[id]?.trash); + + const references = useBlockSuitePageReferences(workspace, pageId); + const referencesToRender = references.filter( + id => allPageMeta[id] && !allPageMeta[id]?.trash + ); + + const pageTitle = page.title || t['Untitled'](); + const pageTitleElement = useMemo(() => { + return ; + }, [icon, pageTitle]); + + const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + id: dragItemId, + data: { + pageId, + pageTitle: pageTitleElement, + removeFromCollection: () => removeFromAllowList(pageId), + }, + }); + return ( - + 0 ? collapsed : undefined} onCollapsedChange={setCollapsed} postfix={ - - } - > - - - - + } + ref={setNodeRef} + {...attributes} + {...listeners} > {page.title || t['Untitled']()} 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 5598ba21e..567f76f32 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 @@ -28,6 +28,37 @@ export const title = style({ overflow: 'hidden', textOverflow: 'ellipsis', }); + +globalStyle(`[data-draggable=true] ${title}: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] ${title}:hover:before`, { + height: 12, + opacity: 1, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${title}`, { + opacity: 0.5, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${title}:before`, { + height: 32, + width: 2, + opacity: 1, +}); + export const more = style({ display: 'flex', alignItems: 'center', 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 new file mode 100644 index 000000000..16e0186e3 --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx @@ -0,0 +1,16 @@ +import * as styles from '../favorite/styles.css'; + +export const DragMenuItemOverlay = ({ + pageTitle, + icon, +}: { + icon: React.ReactNode; + pageTitle: React.ReactNode; +}) => { + return ( +
+ {icon} + {pageTitle} +
+ ); +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx new file mode 100644 index 000000000..324cc6d06 --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx @@ -0,0 +1,155 @@ +import { + MenuIcon, + MenuItem, + type MenuItemProps, + MenuSeparator, +} from '@affine/component'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + DeleteIcon, + EditIcon, + FavoriteIcon, + FilterMinusIcon, + LinkedPageIcon, +} from '@blocksuite/icons'; +import { type ReactElement, useMemo } from 'react'; + +type OperationItemsProps = { + inFavorites?: boolean; + isReferencePage?: boolean; + inAllowList?: boolean; + onRemoveFromAllowList?: () => void; + setRenameModalOpen?: () => void; + onRename: () => void; + onAddLinkedPage: () => void; + onRemoveFromFavourites?: () => void; + onDelete: () => void; +}; + +export const OperationItems = ({ + inFavorites, + isReferencePage, + inAllowList, + onRemoveFromAllowList, + onRename, + onAddLinkedPage, + onRemoveFromFavourites, + onDelete, +}: OperationItemsProps) => { + const t = useAFFiNEI18N(); + const actions = useMemo< + Array< + | { + icon: ReactElement; + name: string; + click: () => void; + type?: MenuItemProps['type']; + element?: undefined; + } + | { + element: ReactElement; + } + > + >( + () => [ + { + icon: ( + + + + ), + name: t['Rename'](), + click: onRename, + }, + { + icon: ( + + + + ), + name: t['com.affine.page-operation.add-linked-page'](), + click: onAddLinkedPage, + }, + ...(inFavorites && onRemoveFromFavourites && !isReferencePage + ? [ + { + icon: ( + + + + ), + name: t['Remove from favorites'](), + click: onRemoveFromFavourites, + }, + { + element: , + }, + ] + : []), + ...(inAllowList && onRemoveFromAllowList + ? [ + { + icon: ( + + + + ), + name: t['Remove special filter'](), + click: onRemoveFromAllowList, + }, + { + element: , + }, + ] + : []), + ...(isReferencePage + ? [ + { + element: , + }, + ] + : []), + { + icon: ( + + + + ), + name: t['com.affine.trashOperation.delete'](), + click: onDelete, + type: 'danger', + }, + ], + [ + onRename, + onAddLinkedPage, + inFavorites, + onRemoveFromFavourites, + isReferencePage, + t, + inAllowList, + onRemoveFromAllowList, + onDelete, + ] + ); + return ( + <> + {actions.map(action => { + if (action.element) { + return action.element; + } + return ( + + {action.name} + + ); + })} + + ); +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx new file mode 100644 index 000000000..6cc43847f --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx @@ -0,0 +1,92 @@ +import { toast } from '@affine/component'; +import { IconButton } from '@affine/component/ui/button'; +import { Menu } from '@affine/component/ui/menu'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { MoreHorizontalIcon } from '@blocksuite/icons'; +import type { Workspace } from '@blocksuite/store'; +import { useCallback } from 'react'; + +import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper'; +import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper'; +import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils'; +import { OperationItems } from './operation-item'; + +export type OperationMenuButtonProps = { + pageId: string; + workspace: Workspace; + pageTitle: string; + setRenameModalOpen: () => void; + inFavorites?: boolean; + isReferencePage?: boolean; + inAllowList?: boolean; + removeFromAllowList?: (id: string) => void; +}; + +export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { + const { + workspace, + pageId, + pageTitle, + setRenameModalOpen, + removeFromAllowList, + inAllowList, + inFavorites, + isReferencePage, + } = props; + const t = useAFFiNEI18N(); + const { createLinkedPage } = usePageHelper(workspace); + const { setTrashModal } = useTrashModalHelper(workspace); + const { removeFromFavorite } = useBlockSuiteMetaHelper(workspace); + + const handleRename = useCallback(() => { + setRenameModalOpen?.(); + }, [setRenameModalOpen]); + + const handleAddLinkedPage = useCallback(() => { + createLinkedPage(pageId); + toast(t['com.affine.toastMessage.addLinkedPage']()); + }, [createLinkedPage, pageId, t]); + + const handleRemoveFromFavourites = useCallback(() => { + removeFromFavorite(pageId); + toast(t['com.affine.toastMessage.removedFavorites']()); + }, [pageId, removeFromFavorite, t]); + + const handleDelete = useCallback(() => { + setTrashModal({ + open: true, + pageIds: [pageId], + pageTitles: [pageTitle], + }); + }, [pageId, pageTitle, setTrashModal]); + + const handleRemoveFromAllowList = useCallback(() => { + removeFromAllowList?.(pageId); + }, [pageId, removeFromAllowList]); + + return ( + + } + > + + + + + ); +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx new file mode 100644 index 000000000..fd2ed694c --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx @@ -0,0 +1,65 @@ +import { toast } from '@affine/component'; +import { RenameModal } from '@affine/component/rename-modal'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { Workspace } from '@blocksuite/store'; +import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useCallback, useState } from 'react'; + +import { AddFavouriteButton } from '../favorite/add-favourite-button'; +import * as styles from '../favorite/styles.css'; +import { OperationMenuButton } from './operation-menu-button'; + +type PostfixItemProps = { + workspace: Workspace; + pageId: string; + pageTitle: string; + inFavorites?: boolean; + isReferencePage?: boolean; + inAllowList?: boolean; + removeFromAllowList?: (id: string) => void; +}; + +export const PostfixItem = ({ ...props }: PostfixItemProps) => { + const { workspace, pageId, pageTitle } = props; + const t = useAFFiNEI18N(); + const [open, setOpen] = useState(false); + const { setPageTitle } = usePageMetaHelper(workspace); + + const handleRename = useCallback( + (newName: string) => { + setPageTitle(pageId, newName); + setOpen(false); + toast(t['com.affine.toastMessage.rename']()); + }, + [pageId, setPageTitle, t] + ); + + return ( +
{ + // prevent drag + e.stopPropagation(); + }} + onClick={e => { + // prevent jump to page + e.stopPropagation(); + e.preventDefault(); + }} + > + + { + setOpen(true); + }} + {...props} + /> + +
+ ); +}; 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 7ffe6c1e0..767909bd3 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 @@ -9,9 +9,9 @@ import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { pageSettingFamily } from '../../../../atoms'; -import { AddFavouriteButton } from '../favorite/add-favourite-button'; import * as styles from '../favorite/styles.css'; -interface ReferencePageProps { +import { PostfixItem } from './postfix-item'; +export interface ReferencePageProps { workspace: Workspace; pageId: string; metaMapping: Record; @@ -24,10 +24,15 @@ export const ReferencePage = ({ metaMapping, parentIds, }: ReferencePageProps) => { + const t = useAFFiNEI18N(); const params = useParams(); - const setting = useAtomValue(pageSettingFamily(pageId)); const active = params.pageId === pageId; - const icon = setting?.mode === 'edgeless' ? : ; + + const setting = useAtomValue(pageSettingFamily(pageId)); + const icon = useMemo(() => { + return setting?.mode === 'edgeless' ? : ; + }, [setting?.mode]); + const references = useBlockSuitePageReferences(workspace, pageId); const referencesToShow = useMemo(() => { return [ @@ -36,11 +41,14 @@ export const ReferencePage = ({ ), ]; }, [references, metaMapping]); + const [collapsed, setCollapsed] = useState(true); const collapsible = referencesToShow.length > 0; const nestedItem = parentIds.size > 0; + const untitled = !metaMapping[pageId]?.title; - const t = useAFFiNEI18N(); + const pageTitle = metaMapping[pageId]?.title || t['Untitled'](); + return ( } + postfix={ + + } > - {metaMapping[pageId]?.title || t['Untitled']()} + {pageTitle} {collapsible && ( diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx index 05bb8dd86..931e9c910 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx @@ -15,40 +15,21 @@ export const AddFavouriteButton = ({ workspace, pageId, }: AddFavouriteButtonProps) => { - const { createPage } = usePageHelper(workspace); + const { createPage, createLinkedPage } = usePageHelper(workspace); const { setPageMeta } = usePageMetaHelper(workspace); const handleAddFavorite = useAsyncCallback( async e => { if (pageId) { e.stopPropagation(); e.preventDefault(); - const page = createPage(); - await page.load(); - const parentPage = workspace.getPage(pageId); - if (parentPage) { - await parentPage.load(); - const text = parentPage.Text.fromDelta([ - { - insert: ' ', - attributes: { - reference: { - type: 'LinkedPage', - pageId: page.id, - }, - }, - }, - ]); - const [frame] = parentPage.getBlockByFlavour('affine:note'); - frame && parentPage.addBlock('affine:paragraph', { text }, frame.id); - setPageMeta(page.id, {}); - } + createLinkedPage(pageId); } else { const page = createPage(); await page.load(); setPageMeta(page.id, { favorite: true }); } }, - [createPage, setPageMeta, workspace, pageId] + [pageId, createLinkedPage, createPage, setPageMeta] ); return ( 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 b4603695b..6875ffb95 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,15 +1,19 @@ import type { PageMeta } from '@blocksuite/store'; +import { useDroppable } from '@dnd-kit/core'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useMemo } from 'react'; -import { ReferencePage } from '../components/reference-page'; +import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; import type { FavoriteListProps } from '../index'; import EmptyItem from './empty-item'; +import { FavouritePage } from './favourite-page'; +import * as styles from './styles.css'; const emptyPageIdSet = new Set(); export const FavoriteList = ({ workspace }: FavoriteListProps) => { const metas = useBlockSuitePageMeta(workspace); + const dropItemId = getDropItemId('favorites'); const favoriteList = useMemo( () => metas.filter(p => p.favorite && !p.trash), @@ -28,11 +32,20 @@ export const FavoriteList = ({ workspace }: FavoriteListProps) => { [metas] ); + const { setNodeRef, isOver } = useDroppable({ + id: dropItemId, + }); + return ( - <> +
{favoriteList.map((pageMeta, index) => { return ( - { ); })} {favoriteList.length === 0 && } - +
); }; 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-page.tsx new file mode 100644 index 000000000..bc32f778c --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx @@ -0,0 +1,113 @@ +import { MenuLinkItem } from '@affine/component/app-sidebar'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; +import { useDraggable } from '@dnd-kit/core'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references'; +import { useAtomValue } from 'jotai/index'; +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { pageSettingFamily } from '../../../../atoms'; +import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag'; +import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; +import { PostfixItem } from '../components/postfix-item'; +import { + ReferencePage, + type ReferencePageProps, +} from '../components/reference-page'; +import * as styles from './styles.css'; + +export const FavouritePage = ({ + workspace, + pageId, + metaMapping, + parentIds, +}: ReferencePageProps) => { + const t = useAFFiNEI18N(); + const params = useParams(); + const active = params.pageId === pageId; + const dragItemId = getDragItemId('favouritePage', pageId); + + const setting = useAtomValue(pageSettingFamily(pageId)); + const icon = useMemo(() => { + return setting?.mode === 'edgeless' ? : ; + }, [setting?.mode]); + + const references = useBlockSuitePageReferences(workspace, pageId); + const referencesToShow = useMemo(() => { + return [ + ...new Set( + references.filter(ref => metaMapping[ref] && !metaMapping[ref]?.trash) + ), + ]; + }, [references, metaMapping]); + + 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 ; + }, [icon, pageTitle]); + + const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ + id: dragItemId, + data: { + pageId, + pageTitle: pageTitleElement, + }, + }); + + return ( + + + } + > + + {pageTitle} + + + + {referencesToShow.map(id => { + return ( + + ); + })} + + + ); +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts index be889f6b8..db163395a 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts @@ -1,4 +1,4 @@ -import { keyframes, style } from '@vanilla-extract/css'; +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; export const label = style({ selectors: { @@ -57,3 +57,89 @@ export const collapsibleContentInner = style({ display: 'flex', flexDirection: 'column', }); + +export const favItem = style({}); + +globalStyle(`[data-draggable=true] ${favItem}: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] ${favItem}:hover:before`, { + height: 12, + opacity: 1, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${favItem}`, { + opacity: 0.5, +}); + +globalStyle(`[data-draggable=true][data-dragging=true] ${favItem}:before`, { + height: 32, + width: 2, + opacity: 1, +}); + +export const dragPageItemOverlay = style({ + display: 'flex', + alignItems: 'center', + background: 'var(--affine-hover-color-filled)', + boxShadow: 'var(--affine-menu-shadow)', + minHeight: '30px', + maxWidth: '360px', + width: '100%', + fontSize: 'var(--affine-font-sm)', + gap: '8px', + padding: '4px', + borderRadius: '4px', + cursor: 'grabbing', +}); + +globalStyle(`${dragPageItemOverlay} svg`, { + width: '20px', + height: '20px', + color: 'var(--affine-icon-color)', +}); + +globalStyle(`${dragPageItemOverlay} span`, { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', +}); + +export const favoriteList = style({ + selectors: { + '&[data-over="true"]': { + background: 'var(--affine-hover-color-filled)', + borderRadius: '4px', + }, + }, +}); + +export const favoritePostfixItem = style({ + display: 'flex', + alignItems: 'center', +}); + +export const menuItem = style({ + gap: '8px', +}); + +globalStyle(`${menuItem} svg`, { + width: '20px', + height: '20px', + color: 'var(--affine-icon-color)', +}); +globalStyle(`${menuItem}.danger:hover svg`, { + color: 'var(--affine-error-color)', +}); diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index ea4e0e957..76dd5b15c 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -36,6 +36,7 @@ import { useHistoryAtom } from '../../atoms/history'; import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper'; import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info'; import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts'; +import { getDropItemId } from '../../hooks/affine/use-sidebar-drag'; import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper'; import { useRegisterBrowserHistoryCommands } from '../../hooks/use-browser-history-commands'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; @@ -89,9 +90,6 @@ const RouteMenuLinkItem = forwardRef< }); RouteMenuLinkItem.displayName = 'RouteMenuLinkItem'; -// Unique droppable IDs -export const DROPPABLE_SIDEBAR_TRASH = 'trash-folder'; - /** * This is for the whole affine app sidebar. * This component wraps the app sidebar in `@affine/component` with logic and data. @@ -170,8 +168,9 @@ export const RootAppSidebar = ({ }; }, [history, setHistory]); + const dropItemId = getDropItemId('trash'); const trashDroppable = useDroppable({ - id: DROPPABLE_SIDEBAR_TRASH, + id: dropItemId, }); const closeUserWorkspaceList = useCallback(() => { setOpenUserWorkspaceList(false); diff --git a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts new file mode 100644 index 000000000..367354e0c --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts @@ -0,0 +1,175 @@ +import { toast } from '@affine/component'; +import type { DraggableTitleCellData } from '@affine/component/page-list'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core'; +import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useCallback } from 'react'; + +import { useCurrentWorkspace } from '../current/use-current-workspace'; +import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; +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] = useCurrentWorkspace(); + const workspace = currentWorkspace.blockSuiteWorkspace; + const { setTrashModal } = useTrashModalHelper(workspace); + const { addToFavorite, removeFromFavorite } = + useBlockSuiteMetaHelper(workspace); + const { getPageMeta } = usePageMetaHelper(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 = getPageMeta(pageId)?.title ?? t['Untitled'](); + processDrag(e, DropPrefix.SidebarTrash, pageId => { + setTrashModal({ + open: true, + pageIds: [pageId], + pageTitles: [pageTitle], + }); + }); + }, + [getPageMeta, processDrag, setTrashModal, t] + ); + + const processFavouritesDrag = useCallback( + (e: DragEndEvent) => { + const { pageId } = e.active.data.current as DraggableTitleCellData; + const isFavourited = getPageMeta(pageId)?.favorite; + 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 => { + addToFavorite(pageId); + toast(t['com.affine.cmdk.affine.editor.add-to-favourites']()); + }); + }, + [getPageMeta, processDrag, addToFavorite, t] + ); + + const processRemoveDrag = useCallback( + (e: DragEndEvent) => { + if (e.over) { + return; + } + + if (String(e.active.id).startsWith(DragPrefix.FavouriteListItem)) { + const pageId = e.active.data.current?.pageId; + removeFromFavorite(pageId); + 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?.(); + } + }, + + [removeFromFavorite, 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/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index ab848eeb0..f3d482f72 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -7,11 +7,9 @@ import { PageListDragOverlay, } from '@affine/component/page-list'; import { MainContainer, WorkspaceFallback } from '@affine/component/workspace'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { getBlobEngine } from '@affine/workspace/manager'; import { assertExists } from '@blocksuite/global/utils'; -import type { DragEndEvent } from '@dnd-kit/core'; import { DndContext, DragOverlay, @@ -35,14 +33,10 @@ import { AdapterProviderWrapper } from '../components/adapter-worksapce-wrapper' import { AppContainer } from '../components/affine/app-container'; import { SyncAwareness } from '../components/affine/awareness'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; -import { processCollectionsDrag } from '../components/pure/workspace-slider-bar/collections'; -import { - DROPPABLE_SIDEBAR_TRASH, - RootAppSidebar, -} from '../components/root-app-sidebar'; +import { RootAppSidebar } from '../components/root-app-sidebar'; import { WorkspaceUpgrade } from '../components/workspace-upgrade'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; -import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper'; +import { useSidebarDrag } from '../hooks/affine/use-sidebar-drag'; import { useCurrentWorkspace } from '../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands'; @@ -51,7 +45,6 @@ import { CurrentWorkspaceModals, } from '../providers/modal-provider'; import { pathGenerator } from '../shared'; -import { toast } from '../utils'; const CMDKQuickSearchModal = lazy(() => import('../components/pure/cmdk').then(module => ({ @@ -169,7 +162,6 @@ export const WorkspaceLayoutInner = ({ const [currentWorkspace] = useCurrentWorkspace(); const { openPage } = useNavigateHelper(); const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); - const t = useAFFiNEI18N(); useRegisterWorkspaceCommands(); @@ -223,28 +215,7 @@ export const WorkspaceLayoutInner = ({ }) ); - const { removeToTrash: moveToTrash } = useBlockSuiteMetaHelper( - currentWorkspace.blockSuiteWorkspace - ); - - const handleDragEnd = useCallback( - (e: DragEndEvent) => { - // Drag page into trash folder - if ( - e.over?.id === DROPPABLE_SIDEBAR_TRASH && - String(e.active.id).startsWith('page-list-item-') - ) { - const { pageId } = e.active.data.current as DraggableTitleCellData; - // TODO-Doma - // Co-locate `moveToTrash` with the toast for reuse, as they're always used together - moveToTrash(pageId); - toast(t['com.affine.toastMessage.successfullyDeleted']()); - } - // Drag page into Collections - processCollectionsDrag(e); - }, - [moveToTrash, t] - ); + const handleDragEnd = useSidebarDrag(); const { appSettings } = useAppSettingHelper(); const location = useLocation(); diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index 82f2a87f2..a4b4d5b04 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -1,5 +1,6 @@ import { toast } from '@affine/component'; import { + currentCollectionAtom, TrashOperationCell, VirtualizedPageList, } from '@affine/component/page-list'; @@ -8,7 +9,10 @@ import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons'; import type { PageMeta } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; +import { getCurrentStore } from '@toeverything/infra/atom'; import { useCallback } from 'react'; +import { type LoaderFunction } from 'react-router-dom'; +import { NIL } from 'uuid'; import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; import { Header } from '../../components/pure/header'; @@ -41,23 +45,34 @@ const TrashHeader = () => { ); }; +export const loader: LoaderFunction = async () => { + // to fix the bug that the trash page list is not updated when route from collection to trash + // but it's not a good solution, the page will jitter when collection and trash are switched between each other. + // TODO: fix this bug + + const rootStore = getCurrentStore(); + rootStore.set(currentCollectionAtom, NIL); + return null; +}; + export const TrashPage = () => { const [currentWorkspace] = useCurrentWorkspace(); // todo(himself65): refactor to plugin const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; assertExists(blockSuiteWorkspace); - const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + + const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace); const filteredPageMetas = useFilteredPageMetas( 'trash', pageMetas, - currentWorkspace.blockSuiteWorkspace + blockSuiteWorkspace ); + const { restoreFromTrash, permanentlyDeletePage } = useBlockSuiteMetaHelper(blockSuiteWorkspace); - const { isPreferredEdgeless } = usePageHelper( - currentWorkspace.blockSuiteWorkspace - ); + const { isPreferredEdgeless } = usePageHelper(blockSuiteWorkspace); const t = useAFFiNEI18N(); + const pageOperationsRenderer = useCallback( (page: PageMeta) => { const onRestorePage = () => { @@ -81,6 +96,7 @@ export const TrashPage = () => { }, [permanentlyDeletePage, restoreFromTrash, t] ); + return (
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 1cfacda97..8bfa822cf 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -541,6 +541,7 @@ "com.affine.collection-bar.action.tooltip.unpin": "Unpin", "com.affine.collection.addPage.alreadyExists": "Page already exists", "com.affine.collection.addPage.success": "Added successfully", + "com.affine.collection.removePage.success": "Removed successfully", "com.affine.collection.addPages": "Add Pages", "com.affine.collection.addPages.tips": "<0>Add pages: You can freely select pages and add them to the collection.", "com.affine.collection.addRules": "Add Rules", @@ -920,6 +921,8 @@ "com.affine.toastMessage.removedFavorites": "Removed from Favourites", "com.affine.toastMessage.restored": "{{title}} restored", "com.affine.toastMessage.successfullyDeleted": "Successfully deleted", + "com.affine.toastMessage.rename": "Successfully renamed", + "com.affine.toastMessage.addLinkedPage": "Successfully added linked page", "com.affine.today": "Today", "com.affine.trashOperation.delete": "Delete", "com.affine.trashOperation.delete.description": "Once deleted, you can't undo this action. Do you confirm?", @@ -1007,5 +1010,6 @@ "com.affine.history.empty-prompt.description": "This document is such a spring chicken, it hasn't sprouted a single historical sprig yet!", "com.affine.history.confirm-restore-modal.restore": "Restore", "com.affine.history.confirm-restore-modal.hint": "You are about to restore the current version of the page to the latest version available. This action will overwrite any changes made prior to the latest version.", - "com.affine.share-page.header.present": "Present" + "com.affine.share-page.header.present": "Present", + "com.affine.page-operation.add-linked-page": "Add linked page" } 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 deleted file mode 100644 index d3cd38c7c..000000000 --- a/tests/affine-local/e2e/drag-page-to-trash-folder.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { test } from '@affine-test/kit/playwright'; -import { openHomePage } from '@affine-test/kit/utils/load-page'; -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 ({ - page, -}) => { - // TODO-Doma - // Init test db with known workspaces and open "All Pages" page via url directly - { - await openHomePage(page); - await waitForEditorLoad(page); - await page.getByTestId('app-sidebar').getByText('All Pages').click(); - await page.waitForTimeout(500); - } - - const title = 'AFFiNE - not just a note-taking app'; - - await dragTo( - page, - page.locator(`[role="button"]:has-text("${title}")`), - page.getByTestId('app-sidebar').getByText('Trash') - ); - - await expect( - page.getByText('Successfully deleted'), - 'A toast containing success message is shown' - ).toBeVisible(); - - await expect( - page.getByText(title), - 'The deleted post is no longer on the All Page list' - ).toHaveCount(0); - - // TODO-Doma - // Visit trash page via url - await page.getByText('Trash', { exact: true }).click(); - await expect( - page.getByText(title), - 'The deleted post exists in the Trash list' - ).toHaveCount(1); -}); diff --git a/tests/affine-local/e2e/drag-page.spec.ts b/tests/affine-local/e2e/drag-page.spec.ts new file mode 100644 index 000000000..c0ac7e486 --- /dev/null +++ b/tests/affine-local/e2e/drag-page.spec.ts @@ -0,0 +1,145 @@ +import { test } from '@affine-test/kit/playwright'; +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + clickNewPageButton, + dragTo, + getBlockSuiteEditorTitle, + waitForEditorLoad, +} from '@affine-test/kit/utils/page-logic'; +import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; +import { expect, type Locator, type Page } from '@playwright/test'; + +const dragToFavourites = async ( + page: Page, + dragItem: Locator, + pageId: string +) => { + const favourites = page.getByTestId('favourites'); + await dragTo(page, dragItem, favourites); + const favouritePage = page.getByTestId(`favourite-page-${pageId}`); + expect(favouritePage).not.toBeUndefined(); + return favouritePage; +}; + +const dragToCollection = async (page: Page, dragItem: Locator) => { + await page.getByTestId('slider-bar-add-collection-button').click(); + const input = page.getByTestId('input-collection-title'); + await input.isVisible(); + await input.fill('test collection'); + await page.getByTestId('save-collection').click(); + const collection = page.getByTestId('collection-item'); + expect(collection).not.toBeUndefined(); + 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(); + 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(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect( + page.getByText(title), + 'The deleted post is no longer on the All Page list' + ).toHaveCount(0); + await page.waitForTimeout(500); + await page.getByTestId('trash-page').click(); + + await expect( + page.getByText(title), + 'The deleted post exists in the Trash list' + ).toHaveCount(1); +}; + +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); + } + const pageId = page.url().split('/').reverse()[0]; + await clickSideBarAllPageButton(page); + await page.waitForTimeout(500); + + const favouritePage = await dragToFavourites( + page, + page.locator(`[role="button"]:has-text("${title}")`), + pageId + ); + + await dragToTrash(page, title, favouritePage); +}); + +test('drag a page from "All pages" list to collections, 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 clickSideBarAllPageButton(page); + await page.waitForTimeout(500); + + const collectionPage = await dragToCollection( + page, + page.locator(`[role="button"]:has-text("${title}")`) + ); + + await dragToTrash(page, title, collectionPage); +}); + +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 clickSideBarAllPageButton(page); + await page.waitForTimeout(500); + + await dragToTrash( + page, + title, + page.locator(`[role="button"]:has-text("${title}")`) + ); +}); + +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); + } + const pageId = page.url().split('/').reverse()[0]; + await clickSideBarAllPageButton(page); + await page.waitForTimeout(500); + + // drag to favourites + const favouritePage = await dragToFavourites( + page, + page.locator(`[role="button"]:has-text("${title}")`), + pageId + ); + + // drag to collections + await dragToCollection(page, favouritePage); +}); 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 b59187f10..ada071d86 100644 --- a/tests/affine-local/e2e/local-first-collections-items.spec.ts +++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts @@ -60,18 +60,16 @@ test('Show collections items in sidebar', async ({ page }) => { const collectionPage = collections.getByTestId('collection-page').nth(0); expect(await collectionPage.textContent()).toBe('test page'); await collectionPage.hover(); - await collectionPage.getByTestId('collection-page-options').click(); - const deletePage = page - .getByTestId('collection-page-option') - .getByText('Delete'); + await collectionPage + .getByTestId('left-sidebar-page-operation-button') + .click(); + const deletePage = page.getByText('Delete'); await deletePage.click(); await page.getByTestId('confirm-delete-page').click(); expect(await collections.getByTestId('collection-page').count()).toBe(0); await first.hover(); await first.getByTestId('collection-options').click(); - const deleteCollection = page - .getByTestId('collection-option') - .getByText('Delete'); + const deleteCollection = page.getByText('Delete'); await deleteCollection.click(); await page.waitForTimeout(50); expect(await items.count()).toBe(0); @@ -100,13 +98,10 @@ test('edit collection', async ({ page }) => { const first = items.first(); await first.hover(); await first.getByTestId('collection-options').click(); - const editCollection = page - .getByTestId('collection-option') - .getByText('Rename'); + const editCollection = page.getByText('Rename'); await editCollection.click(); - const title = page.getByTestId('input-collection-title'); - await title.fill('123'); - await page.getByTestId('save-collection').click(); + await page.getByTestId('rename-modal-input').fill('123'); + await page.keyboard.press('Enter'); await page.waitForTimeout(100); expect(await first.textContent()).toBe('123'); }); @@ -123,9 +118,8 @@ test('edit collection and change filter date', async ({ page }) => { .getByTestId('collection-option') .getByText('Rename'); await editCollection.click(); - const title = page.getByTestId('input-collection-title'); - await title.fill('123'); - await page.getByTestId('save-collection').click(); + await page.getByTestId('rename-modal-input').fill('123'); + await page.keyboard.press('Enter'); await page.waitForTimeout(100); expect(await first.textContent()).toBe('123'); }); 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 296b47e64..64e31bfd3 100644 --- a/tests/affine-local/e2e/local-first-favorites-items.spec.ts +++ b/tests/affine-local/e2e/local-first-favorites-items.spec.ts @@ -26,7 +26,7 @@ test('Show favorite items in sidebar', async ({ page, workspace }) => { const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); await favoriteBtn.click(); const favoriteListItemInSidebar = page.getByTestId( - 'favorite-list-item-' + newPageId + 'favourite-page-' + newPageId ); expect(await favoriteListItemInSidebar.textContent()).toBe( 'this is a new page to favorite' @@ -55,7 +55,7 @@ test('Show favorite reference in sidebar', async ({ page, workspace }) => { const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); await favoriteBtn.click(); - const favItemTestId = 'favorite-list-item-' + newPageId; + const favItemTestId = 'favourite-page-' + newPageId; const favoriteListItemInSidebar = page.getByTestId(favItemTestId); expect(await favoriteListItemInSidebar.textContent()).toBe( @@ -69,7 +69,7 @@ test('Show favorite reference in sidebar', async ({ page, workspace }) => { await expect(collapseButton).toBeVisible(); await collapseButton.click(); await expect( - page.locator('[data-type="favorite-list-item"] >> text=Another page') + page.locator('[data-type="reference-page"] >> text=Another page') ).toBeVisible(); const currentWorkspace = await workspace.current(); @@ -110,7 +110,7 @@ test("Deleted page's reference will not be shown in sidebar", async ({ // confirm delete await page.locator('button >> text=Delete').click(); - const favItemTestId = 'favorite-list-item-' + newPageId; + const favItemTestId = 'favourite-page-' + newPageId; const favoriteListItemInSidebar = page.getByTestId(favItemTestId); expect(await favoriteListItemInSidebar.textContent()).toBe( @@ -137,7 +137,7 @@ test('Add new favorite page via sidebar', async ({ page }) => { 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=favorite-list-item] >> text=this is a new fav page' + '[data-type=favourite-list-item] >> text=this is a new fav page' ); await expect(favItem).toBeVisible(); }); diff --git a/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx b/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx index e528784b3..e99b4d1e2 100644 --- a/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx +++ b/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx @@ -64,6 +64,7 @@ function useRegisterCommands() { createPage: createMockedPage, importFile: () => Promise.resolve(), isPreferredEdgeless: () => false, + createLinkedPage: createMockedPage, }, }), registerAffineLayoutCommands({