mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-14 00:26:51 +08:00
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:
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -121,7 +121,7 @@ const PageListOperationsCell = ({
|
||||
export const PageListItem = (props: PageListItemProps) => {
|
||||
const pageTitleElement = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.dragPageItemOverlay}>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
@@ -131,7 +131,7 @@ export const PageListItem = (props: PageListItemProps) => {
|
||||
<PageListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
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: {
|
||||
|
||||
@@ -102,6 +102,7 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => {
|
||||
? defaultCollection
|
||||
: collections.find(v => v.id === currentCollectionId) ??
|
||||
defaultCollection;
|
||||
|
||||
return {
|
||||
currentCollection: currentCollection,
|
||||
savedCollections: collections,
|
||||
|
||||
+10
-1
@@ -22,12 +22,14 @@ export const CollectionOperations = ({
|
||||
config,
|
||||
setting,
|
||||
info,
|
||||
openRenameModal,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
info: DeleteCollectionInfo;
|
||||
collection: Collection;
|
||||
config: AllPageListConfig;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
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<
|
||||
| {
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ export const EditCollectionModal = ({
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onConfirm, onOpenChange]
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open: open,
|
||||
onOpenChange: onOpenChange,
|
||||
}}
|
||||
contentOptions={{
|
||||
side: 'left',
|
||||
onPointerDownOutside: handleRename,
|
||||
sideOffset: -12,
|
||||
}}
|
||||
items={
|
||||
<Input
|
||||
autoFocus
|
||||
width={220}
|
||||
style={{ height: 34 }}
|
||||
defaultValue={value}
|
||||
onChange={setValue}
|
||||
onEnter={handleRename}
|
||||
data-testid="rename-modal-input"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div></div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -48,12 +48,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
endFix,
|
||||
onEnter,
|
||||
onKeyDown,
|
||||
autoFocus,
|
||||
...otherProps
|
||||
}: InputProps,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
|
||||
const handleAutoFocus = useCallback((ref: HTMLInputElement | null) => {
|
||||
if (ref) {
|
||||
window.setTimeout(() => ref.focus(), 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(inputWrapper, className, {
|
||||
@@ -83,7 +90,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
large: size === 'large',
|
||||
'extra-large': size === 'extraLarge',
|
||||
})}
|
||||
ref={ref}
|
||||
ref={autoFocus ? handleAutoFocus : ref}
|
||||
disabled={disabled}
|
||||
style={inputStyle}
|
||||
onFocus={useCallback(
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
+1
-5
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+51
-38
@@ -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 }}>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './collections-list';
|
||||
export { Page } from './page';
|
||||
export { PageOperations } from './page';
|
||||
|
||||
+49
-126
@@ -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>
|
||||
|
||||
+31
@@ -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',
|
||||
|
||||
+16
@@ -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>
|
||||
);
|
||||
};
|
||||
+155
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+92
@@ -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>
|
||||
);
|
||||
};
|
||||
+65
@@ -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>
|
||||
);
|
||||
};
|
||||
+24
-9
@@ -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 && (
|
||||
|
||||
+3
-22
@@ -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 (
|
||||
|
||||
+17
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+113
@@ -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>
|
||||
);
|
||||
};
|
||||
+87
-1
@@ -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)',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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:</0> 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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ function useRegisterCommands() {
|
||||
createPage: createMockedPage,
|
||||
importFile: () => Promise.resolve(),
|
||||
isPreferredEdgeless: () => false,
|
||||
createLinkedPage: createMockedPage,
|
||||
},
|
||||
}),
|
||||
registerAffineLayoutCommands({
|
||||
|
||||
Reference in New Issue
Block a user