feat(core): support sidebar page item dnd (#5132)

Added the ability to drag page items from the `all pages` view to the sidebar, including `favourites,` `collection` and `trash`. Page items in `favourites` and `collection` can also be dragged between each other. However, linked subpages cannot be dragged.

Additionally, an operation menu and ‘add’ button have been provided for the sidebar’s page items, enabling the addition of a subpage, renaming, deletion or removal from the sidebar.

On the code front, the `useSidebarDrag` hooks have been implemented for consolidating drag events. The functions `getDragItemId` and `getDropItemId` have been created, and they accept type and ID to obtain itemId.

https://github.com/toeverything/AFFiNE/assets/102217452/d06bac18-3c28-41c9-a7d4-72de955d7b11
This commit is contained in:
JimmFly
2023-12-12 16:04:57 +00:00
parent b782b3fb1b
commit f4a52c031f
34 changed files with 1191 additions and 328 deletions

View File

@@ -81,11 +81,7 @@ const NameWorkspaceContent = ({
{...props}
>
<Input
ref={ref => {
if (ref) {
window.setTimeout(() => ref.focus(), 0);
}
}}
autoFocus
data-testid="create-workspace-input"
onKeyDown={handleKeyDown}
placeholder={t['com.affine.nameWorkspace.placeholder']()}

View File

@@ -67,11 +67,7 @@ export const WorkspaceDeleteModal = ({
)}
<div className={styles.inputContent}>
<Input
ref={ref => {
if (ref) {
window.setTimeout(() => ref.focus(), 0);
}
}}
autoFocus
onChange={setDeleteStr}
data-testid="delete-workspace-input"
onEnter={handleOnEnter}

View File

@@ -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,

View File

@@ -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 (
<Collapsible.Root open={!collapsed}>
<Collapsible.Root open={!collapsed} ref={setNodeRef}>
<SidebarMenuLinkItem
data-testid="collection-item"
data-type="collection-list-item"
ref={setNodeRef}
onCollapsedChange={setCollapsed}
active={isOver || currentPath === path}
icon={<AnimatedCollectionsIcon closed={isOver} />}
to={path}
postfix={
<div onClick={stopPropagation}>
<div
onClick={stopPropagation}
style={{ display: 'flex', alignItems: 'center' }}
>
<CollectionOperations
info={info}
collection={collection}
setting={setting}
config={config}
openRenameModal={handleOpen}
>
<IconButton
data-testid="collection-options"
type="plain"
withoutHoverStyle
size="small"
style={{ marginLeft: 4 }}
>
<MoreHorizontalIcon />
</IconButton>
</CollectionOperations>
<RenameModal
open={open}
onOpenChange={setOpen}
onRename={onRename}
currentName={collection.name}
/>
</div>
}
collapsed={pagesToRender.length > 0 ? collapsed : undefined}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>{collection.name}</div>
</div>
<span>{collection.name}</span>
</SidebarMenuLinkItem>
<Collapsible.Content className={styles.collapsibleContent}>
<div style={{ marginLeft: 20, marginTop: -4 }}>

View File

@@ -1,3 +1,2 @@
export * from './collections-list';
export { Page } from './page';
export { PageOperations } from './page';

View File

@@ -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: (
<MenuIcon>
<FilterMinusIcon />
</MenuIcon>
),
name: t['Remove special filter'](),
click: () => removeFromAllowList(page.id),
},
{
element: (
<div key="divider" className={styles.menuDividerStyle}></div>
),
},
]
: []),
{
icon: (
<MenuIcon>
<DeleteIcon />
</MenuIcon>
),
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 (
<MenuItem
data-testid="collection-page-option"
key={action.name}
type={action.type}
preFix={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</>
);
};
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' ? <EdgelessIcon /> : <PageIcon />;
const references = useBlockSuitePageReferences(workspace, pageId);
const dragItemId = getDragItemId('collectionPage', pageId);
const icon = useMemo(() => {
return setting?.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [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 <DragMenuItemOverlay icon={icon} pageTitle={pageTitle} />;
}, [icon, pageTitle]);
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: dragItemId,
data: {
pageId,
pageTitle: pageTitleElement,
removeFromCollection: () => removeFromAllowList(pageId),
},
});
return (
<Collapsible.Root open={!collapsed}>
<Collapsible.Root
open={!collapsed}
data-draggable={true}
data-dragging={isDragging}
>
<CollectionItem
data-testid="collection-page"
data-type="collection-list-item"
@@ -153,25 +84,17 @@ export const Page = ({
collapsed={referencesToRender.length > 0 ? collapsed : undefined}
onCollapsedChange={setCollapsed}
postfix={
<Menu
items={
<PageOperations
inAllowList={inAllowList}
removeFromAllowList={removeFromAllowList}
page={page}
workspace={workspace}
/>
}
>
<IconButton
data-testid="collection-page-options"
type="plain"
withoutHoverStyle
>
<MoreHorizontalIcon />
</IconButton>
</Menu>
<PostfixItem
workspace={workspace}
pageId={pageId}
pageTitle={pageTitle}
removeFromAllowList={removeFromAllowList}
inAllowList={inAllowList}
/>
}
ref={setNodeRef}
{...attributes}
{...listeners}
>
{page.title || t['Untitled']()}
</CollectionItem>

View File

@@ -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',

View File

@@ -0,0 +1,16 @@
import * as styles from '../favorite/styles.css';
export const DragMenuItemOverlay = ({
pageTitle,
icon,
}: {
icon: React.ReactNode;
pageTitle: React.ReactNode;
}) => {
return (
<div className={styles.dragPageItemOverlay}>
{icon}
<span>{pageTitle}</span>
</div>
);
};

View File

@@ -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: (
<MenuIcon>
<EditIcon />
</MenuIcon>
),
name: t['Rename'](),
click: onRename,
},
{
icon: (
<MenuIcon>
<LinkedPageIcon />
</MenuIcon>
),
name: t['com.affine.page-operation.add-linked-page'](),
click: onAddLinkedPage,
},
...(inFavorites && onRemoveFromFavourites && !isReferencePage
? [
{
icon: (
<MenuIcon>
<FavoriteIcon />
</MenuIcon>
),
name: t['Remove from favorites'](),
click: onRemoveFromFavourites,
},
{
element: <MenuSeparator />,
},
]
: []),
...(inAllowList && onRemoveFromAllowList
? [
{
icon: (
<MenuIcon>
<FilterMinusIcon />
</MenuIcon>
),
name: t['Remove special filter'](),
click: onRemoveFromAllowList,
},
{
element: <MenuSeparator />,
},
]
: []),
...(isReferencePage
? [
{
element: <MenuSeparator />,
},
]
: []),
{
icon: (
<MenuIcon>
<DeleteIcon />
</MenuIcon>
),
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 (
<MenuItem
data-testid="sidebar-page-option-item"
key={action.name}
type={action.type}
preFix={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</>
);
};

View File

@@ -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 (
<Menu
items={
<OperationItems
onAddLinkedPage={handleAddLinkedPage}
onDelete={handleDelete}
onRemoveFromAllowList={handleRemoveFromAllowList}
onRemoveFromFavourites={handleRemoveFromFavourites}
onRename={handleRename}
inAllowList={inAllowList}
inFavorites={inFavorites}
isReferencePage={isReferencePage}
/>
}
>
<IconButton
size="small"
type="plain"
data-testid="left-sidebar-page-operation-button"
style={{ marginLeft: 4 }}
>
<MoreHorizontalIcon />
</IconButton>
</Menu>
);
};

View File

@@ -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 (
<div
className={styles.favoritePostfixItem}
onMouseDown={e => {
// prevent drag
e.stopPropagation();
}}
onClick={e => {
// prevent jump to page
e.stopPropagation();
e.preventDefault();
}}
>
<AddFavouriteButton {...props} />
<OperationMenuButton
setRenameModalOpen={() => {
setOpen(true);
}}
{...props}
/>
<RenameModal
open={open}
onOpenChange={setOpen}
onRename={handleRename}
currentName={pageTitle}
/>
</div>
);
};

View File

@@ -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<string, PageMeta>;
@@ -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' ? <EdgelessIcon /> : <PageIcon />;
const setting = useAtomValue(pageSettingFamily(pageId));
const icon = useMemo(() => {
return setting?.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [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 (
<Collapsible.Root
className={styles.favItemWrapper}
@@ -48,17 +56,24 @@ export const ReferencePage = ({
open={!collapsed}
>
<MenuLinkItem
data-type="favorite-list-item"
data-testid={`favorite-list-item-${pageId}`}
data-type="reference-page"
data-testid={`reference-page-${pageId}`}
active={active}
to={`/workspace/${workspace.id}/${pageId}`}
icon={icon}
collapsed={collapsible ? collapsed : undefined}
onCollapsedChange={setCollapsed}
postfix={<AddFavouriteButton workspace={workspace} pageId={pageId} />}
postfix={
<PostfixItem
workspace={workspace}
pageId={pageId}
pageTitle={pageTitle}
isReferencePage={true}
/>
}
>
<span className={styles.label} data-untitled={untitled}>
{metaMapping[pageId]?.title || t['Untitled']()}
{pageTitle}
</span>
</MenuLinkItem>
{collapsible && (

View File

@@ -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 (

View File

@@ -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<string>();
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 (
<>
<div
className={styles.favoriteList}
data-testid="favourites"
ref={setNodeRef}
data-over={isOver}
>
{favoriteList.map((pageMeta, index) => {
return (
<ReferencePage
<FavouritePage
key={`${pageMeta}-${index}`}
metaMapping={metaMapping}
pageId={pageMeta.id}
@@ -43,7 +56,7 @@ export const FavoriteList = ({ workspace }: FavoriteListProps) => {
);
})}
{favoriteList.length === 0 && <EmptyItem />}
</>
</div>
);
};

View File

@@ -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' ? <EdgelessIcon /> : <PageIcon />;
}, [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 <DragMenuItemOverlay icon={icon} pageTitle={pageTitle} />;
}, [icon, pageTitle]);
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: dragItemId,
data: {
pageId,
pageTitle: pageTitleElement,
},
});
return (
<Collapsible.Root
className={styles.favItemWrapper}
data-nested={nestedItem}
open={!collapsed}
data-draggable={true}
data-dragging={isDragging}
>
<MenuLinkItem
data-testid={`favourite-page-${pageId}`}
data-type="favourite-list-item"
icon={icon}
className={styles.favItem}
active={active}
to={`/workspace/${workspace.id}/${pageId}`}
collapsed={collapsible ? collapsed : undefined}
onCollapsedChange={setCollapsed}
ref={setNodeRef}
{...attributes}
{...listeners}
postfix={
<PostfixItem
workspace={workspace}
pageId={pageId}
pageTitle={pageTitle}
inFavorites={true}
/>
}
>
<span className={styles.label} data-untitled={untitled}>
{pageTitle}
</span>
</MenuLinkItem>
<Collapsible.Content className={styles.collapsibleContent}>
{referencesToShow.map(id => {
return (
<ReferencePage
key={id}
workspace={workspace}
pageId={id}
metaMapping={metaMapping}
parentIds={new Set([pageId])}
/>
);
})}
</Collapsible.Content>
</Collapsible.Root>
);
};

View File

@@ -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)',
});

View File

@@ -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);

View File

@@ -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,
]
);
};

View File

@@ -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();

View File

@@ -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 (
<div className={styles.root}>
<TrashHeader />