fix(core): optimize explorer's dnd behaviors (#7769)

close AF-1198, AF-1169, AF-1204, AF-1167, AF-1168

- **fix**: empty favorite cannot be dropped(AF-1198)
- **fix**: folder close animation has a unexpected delay
- **fix**: mount explorer's DropEffect to body to avoid clipping(AF-1169)
- **feat**: drop on empty organize to create folder and put item into it(AF-1204)
- **feat**: only show explorer section's action when hovered(AF-1168)
- **feat**: animate folder icon when opened(AF-1167)
- **chore**: extract dnd related `dropEffect`, `canDrop` functions outside component
This commit is contained in:
CatsJuice
2024-08-07 08:29:19 +00:00
parent f35dc744dd
commit 75a308ac79
21 changed files with 309 additions and 211 deletions

View File

@@ -2,7 +2,7 @@
"v": "5.12.1",
"fr": 60,
"ip": 0,
"op": 89,
"op": 6,
"w": 240,
"h": 240,
"nm": "folder",

View File

@@ -7,25 +7,31 @@ import animationData from './folder-icon.json';
import * as styles from './styles.css';
export interface FolderIconProps {
closed: boolean; // eg, when folder icon is a "dragged over" state
open: boolean; // eg, when folder icon is a "dragged over" state
className?: string;
speed?: number;
}
// animated folder icon that has two states: closed and opened
export const AnimatedFolderIcon = ({ closed, className }: FolderIconProps) => {
export const AnimatedFolderIcon = ({
open,
className,
speed = 0.5,
}: FolderIconProps) => {
const lottieRef: LottieRef = useRef(null);
useEffect(() => {
if (lottieRef.current) {
const lottie = lottieRef.current;
if (closed) {
lottie.setSpeed(speed);
if (open) {
lottie.setDirection(1);
} else {
lottie.setDirection(-1);
}
lottie.play();
}
}, [closed]);
}, [open, speed]);
return (
<Lottie

View File

@@ -2,10 +2,12 @@ import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const actions = style({
const baseAction = style({
display: 'flex',
gap: 8,
opacity: 0,
});
export const root = style({
fontSize: cssVar('fontXs'),
height: 20,
@@ -23,11 +25,22 @@ export const root = style({
[`&[data-collapsible="true"]:hover`]: {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
[`&[data-collapsible="true"]:hover:has(${actions}:hover)`]: {
[`&[data-collapsible="true"]:hover:has(${baseAction}:hover)`]: {
backgroundColor: 'transparent',
},
},
});
export const actions = style([
baseAction,
{
selectors: {
[`${root}:hover &`]: {
opacity: 1,
},
},
},
]);
export const label = style({
color: cssVarV2('text/tertiary'),
fontWeight: 500,

View File

@@ -1,6 +1,15 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({});
export const content = style({
paddingTop: 6,
});
export const header = style({
selectors: {
'&[data-dragged-over="true"]': {
background: cssVarV2('layer/background/hoverOverlay'),
},
},
});

View File

@@ -11,7 +11,7 @@ import {
import { ExplorerService } from '../../services/explorer';
import type { CollapsibleSectionName } from '../../types';
import { content, root } from './collapsible-section.css';
import { content, header, root } from './collapsible-section.css';
interface CollapsibleSectionProps extends PropsWithChildren {
name: CollapsibleSectionName;
@@ -67,7 +67,7 @@ export const CollapsibleSection = ({
setCollapsed={setCollapsed}
collapsed={collapsed}
ref={headerRef}
className={headerClassName}
className={clsx(header, headerClassName)}
>
{actions}
</CategoryDivider>

View File

@@ -9,6 +9,13 @@ export const content = style({
alignItems: 'center',
gap: 4,
padding: '12px 0px',
borderRadius: 8,
selectors: {
// assume that the section can be dragged over
'&[data-dragged-over="true"]': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
},
});
export const iconWrapper = style({
width: 36,

View File

@@ -1,8 +1,10 @@
import { Button } from '@affine/component';
import clsx from 'clsx';
import {
cloneElement,
forwardRef,
type HTMLAttributes,
type ReactElement,
type Ref,
type SVGProps,
} from 'react';
@@ -10,7 +12,7 @@ import {
import * as styles from './empty-section.css';
interface ExplorerEmptySectionProps extends HTMLAttributes<HTMLDivElement> {
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
icon: ((props: SVGProps<SVGSVGElement>) => JSX.Element) | ReactElement;
message: string;
messageTestId?: string;
actionText?: string;
@@ -30,11 +32,16 @@ export const ExplorerEmptySection = forwardRef(function ExplorerEmptySection(
}: ExplorerEmptySectionProps,
ref: Ref<HTMLDivElement>
) {
const icon =
typeof Icon === 'function' ? (
<Icon className={styles.icon} />
) : (
cloneElement(Icon, { className: styles.icon })
);
return (
<div className={clsx(styles.content, className)} ref={ref} {...attrs}>
<div className={styles.iconWrapper}>
<Icon className={styles.icon} />
</div>
<div className={styles.iconWrapper}>{icon}</div>
<div data-testid={messageTestId} className={styles.message}>
{message}
</div>

View File

@@ -31,11 +31,23 @@ import {
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
import type { ExplorerTreeNodeIcon } from '../../tree/node';
import { ExplorerDocNode } from '../doc';
import type { GenericExplorerNode } from '../types';
import { Empty } from './empty';
import { useExplorerCollectionNodeOperations } from './operations';
const CollectionIcon: ExplorerTreeNodeIcon = ({
className,
draggedOver,
treeInstruction,
}) => (
<AnimatedCollectionsIcon
className={className}
closed={!!draggedOver && treeInstruction?.type === 'make-child'}
/>
);
export const ExplorerCollectionNode = ({
collectionId,
onDrop,
@@ -202,12 +214,7 @@ export const ExplorerCollectionNode = ({
return (
<>
<ExplorerTreeNode
icon={({ draggedOver, className, treeInstruction }) => (
<AnimatedCollectionsIcon
className={className}
closed={!!draggedOver && treeInstruction?.type === 'make-child'}
/>
)}
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
dndData={dndData}
onDrop={handleDropOnCollection}

View File

@@ -39,6 +39,7 @@ import { difference } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
import type { ExplorerTreeNodeIcon } from '../../tree/node';
import type { NodeOperation } from '../../tree/types';
import { ExplorerCollectionNode } from '../collection';
import { ExplorerDocNode } from '../doc';
@@ -150,6 +151,21 @@ export const ExplorerFolderNode = ({
return;
};
// Define outside the `ExplorerFolderNodeFolder` to avoid re-render(the close animation won't play)
const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
collapsed,
className,
draggedOver,
treeInstruction,
}) => (
<AnimatedFolderIcon
className={className}
open={
!collapsed || (!!draggedOver && treeInstruction?.type === 'make-child')
}
/>
);
export const ExplorerFolderNodeFolder = ({
node,
onDrop,
@@ -758,12 +774,7 @@ export const ExplorerFolderNodeFolder = ({
return (
<ExplorerTreeNode
icon={({ draggedOver, className, treeInstruction }) => (
<AnimatedFolderIcon
className={className}
closed={!!draggedOver && treeInstruction?.type === 'make-child'}
/>
)}
icon={ExplorerFolderIcon}
name={name}
dndData={dndData}
onDrop={handleDropOnFolder}

View File

@@ -0,0 +1,45 @@
import type { DropTargetOptions } from '@affine/component';
import { isFavoriteSupportType } from '@affine/core/modules/favorite';
import type { AffineDNDData } from '@affine/core/types/dnd';
import type { ExplorerTreeNodeDropEffect } from '../../tree';
export const favoriteChildrenDropEffect: ExplorerTreeNodeDropEffect = data => {
if (
data.treeInstruction?.type === 'reorder-above' ||
data.treeInstruction?.type === 'reorder-below'
) {
if (
data.source.data.from?.at === 'explorer:favorite:list' &&
data.source.data.entity?.type &&
isFavoriteSupportType(data.source.data.entity.type)
) {
return 'move';
} else if (
data.source.data.entity?.type &&
isFavoriteSupportType(data.source.data.entity.type)
) {
return 'link';
}
}
return; // not supported
};
export const favoriteRootDropEffect: ExplorerTreeNodeDropEffect = data => {
const sourceType = data.source.data.entity?.type;
if (sourceType && isFavoriteSupportType(sourceType)) {
return 'link';
}
return;
};
export const favoriteRootCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
data => {
return data.source.data.entity?.type
? isFavoriteSupportType(data.source.data.entity.type)
: false;
};
export const favoriteChildrenCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
// Same as favoriteRootCanDrop
data => favoriteRootCanDrop(data);

View File

@@ -1,6 +1,5 @@
import {
type DropTargetDropEvent,
type DropTargetOptions,
Skeleton,
useDropTarget,
} from '@affine/component';
@@ -9,19 +8,18 @@ import { useI18n } from '@affine/i18n';
import { FavoriteIcon } from '@blocksuite/icons/rc';
import { ExplorerEmptySection } from '../../layouts/empty-section';
import { DropEffect, type ExplorerTreeNodeDropEffect } from '../../tree';
import { DropEffect } from '../../tree';
import { favoriteRootCanDrop, favoriteRootDropEffect } from './dnd';
export const RootEmpty = ({
onDrop,
canDrop,
isLoading,
dropEffect,
}: {
interface RootEmptyProps {
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
dropEffect?: ExplorerTreeNodeDropEffect;
isLoading?: boolean;
}) => {
}
const RootEmptyLoading = () => {
return <Skeleton />;
};
const RootEmptyReady = ({ onDrop }: Omit<RootEmptyProps, 'isLoading'>) => {
const t = useI18n();
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
@@ -31,15 +29,11 @@ export const RootEmpty = ({
at: 'explorer:favorite:root',
},
onDrop: onDrop,
canDrop: canDrop,
canDrop: favoriteRootCanDrop,
}),
[onDrop, canDrop]
[onDrop]
);
if (isLoading) {
return <Skeleton />;
}
return (
<ExplorerEmptySection
ref={dropTargetRef}
@@ -47,13 +41,10 @@ export const RootEmpty = ({
message={t['com.affine.rootAppSidebar.favorites.empty']()}
messageTestId="slider-bar-favorites-empty-message"
>
{dropEffect && draggedOverDraggable && (
{draggedOverDraggable && (
<DropEffect
position={{
x: draggedOverPosition.relativeX,
y: draggedOverPosition.relativeY,
}}
dropEffect={dropEffect({
position={draggedOverPosition}
dropEffect={favoriteRootDropEffect({
source: draggedOverDraggable,
treeInstruction: null,
})}
@@ -62,3 +53,7 @@ export const RootEmpty = ({
</ExplorerEmptySection>
);
};
export const RootEmpty = ({ isLoading, ...props }: RootEmptyProps) => {
return isLoading ? <RootEmptyLoading /> : <RootEmptyReady {...props} />;
};

View File

@@ -1,13 +1,11 @@
import {
type DropTargetDropEvent,
type DropTargetOptions,
IconButton,
useDropTarget,
} from '@affine/component';
import { track } from '@affine/core/mixpanel';
import {
DropEffect,
type ExplorerTreeNodeDropEffect,
ExplorerTreeRoot,
} from '@affine/core/modules/explorer/views/tree';
import type { FavoriteSupportType } from '@affine/core/modules/favorite';
@@ -20,7 +18,7 @@ import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
import { type MouseEventHandler, useCallback, useMemo } from 'react';
import { type MouseEventHandler, useCallback } from 'react';
import { ExplorerService } from '../../../services/explorer';
import { CollapsibleSection } from '../../layouts/collapsible-section';
@@ -28,8 +26,13 @@ import { ExplorerCollectionNode } from '../../nodes/collection';
import { ExplorerDocNode } from '../../nodes/doc';
import { ExplorerFolderNode } from '../../nodes/folder';
import { ExplorerTagNode } from '../../nodes/tag';
import {
favoriteChildrenCanDrop,
favoriteChildrenDropEffect,
favoriteRootCanDrop,
favoriteRootDropEffect,
} from './dnd';
import { RootEmpty } from './empty';
import * as styles from './styles.css';
export const ExplorerFavorites = () => {
const { favoriteService, docsService, workbenchService, explorerService } =
@@ -69,25 +72,6 @@ export const ExplorerFavorites = () => {
[explorerSection, favoriteService.favoriteList]
);
const handleDropEffect = useCallback<ExplorerTreeNodeDropEffect>(data => {
if (
data.source.data.entity?.type &&
isFavoriteSupportType(data.source.data.entity.type)
) {
return 'link';
}
return;
}, []);
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
() => data => {
return data.source.data.entity?.type
? isFavoriteSupportType(data.source.data.entity.type)
: false;
},
[]
);
const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback(
e => {
const newDoc = docsService.createDoc();
@@ -163,40 +147,6 @@ export const ExplorerFavorites = () => {
[favoriteService]
);
const handleChildrenDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
data => {
if (
data.treeInstruction?.type === 'reorder-above' ||
data.treeInstruction?.type === 'reorder-below'
) {
if (
data.source.data.from?.at === 'explorer:favorite:list' &&
data.source.data.entity?.type &&
isFavoriteSupportType(data.source.data.entity.type)
) {
return 'move';
} else if (
data.source.data.entity?.type &&
isFavoriteSupportType(data.source.data.entity.type)
) {
return 'link';
}
}
return; // not supported
},
[]
);
const handleChildrenCanDrop = useMemo<
DropTargetOptions<AffineDNDData>['canDrop']
>(
() => args =>
args.source.data.entity?.type
? isFavoriteSupportType(args.source.data.entity.type)
: false,
[]
);
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
useDropTarget<AffineDNDData>(
() => ({
@@ -204,9 +154,9 @@ export const ExplorerFavorites = () => {
at: 'explorer:favorite:root',
},
onDrop: handleDrop,
canDrop: handleCanDrop,
canDrop: favoriteRootCanDrop,
}),
[handleCanDrop, handleDrop]
[handleDrop]
);
return (
@@ -216,7 +166,6 @@ export const ExplorerFavorites = () => {
headerRef={dropTargetRef}
testId="explorer-favorites"
headerTestId="explorer-favorite-category-divider"
headerClassName={styles.draggedOverHighlight}
actions={
<>
<IconButton
@@ -233,11 +182,8 @@ export const ExplorerFavorites = () => {
</IconButton>
{draggedOverDraggable && (
<DropEffect
position={{
x: draggedOverPosition.relativeX,
y: draggedOverPosition.relativeY,
}}
dropEffect={handleDropEffect({
position={draggedOverPosition}
dropEffect={favoriteRootDropEffect({
source: draggedOverDraggable,
treeInstruction: null,
})}
@@ -247,22 +193,13 @@ export const ExplorerFavorites = () => {
}
>
<ExplorerTreeRoot
placeholder={
<RootEmpty
onDrop={handleDrop}
canDrop={handleCanDrop}
dropEffect={handleDropEffect}
isLoading={isLoading}
/>
}
placeholder={<RootEmpty onDrop={handleDrop} isLoading={isLoading} />}
>
{favorites.map(favorite => (
<ExplorerFavoriteNode
key={favorite.id}
favorite={favorite}
onDrop={handleOnChildrenDrop}
dropEffect={handleChildrenDropEffect}
canDrop={handleChildrenCanDrop}
/>
))}
</ExplorerTreeRoot>
@@ -276,14 +213,11 @@ const childLocation = {
const ExplorerFavoriteNode = ({
favorite,
onDrop,
canDrop,
dropEffect,
}: {
favorite: {
id: string;
type: FavoriteSupportType;
};
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
onDrop: (
favorite: {
id: string;
@@ -291,7 +225,6 @@ const ExplorerFavoriteNode = ({
},
data: DropTargetDropEvent<AffineDNDData>
) => void;
dropEffect: ExplorerTreeNodeDropEffect;
}) => {
const handleOnChildrenDrop = useCallback(
(data: DropTargetDropEvent<AffineDNDData>) => {
@@ -305,8 +238,8 @@ const ExplorerFavoriteNode = ({
docId={favorite.id}
location={childLocation}
onDrop={handleOnChildrenDrop}
dropEffect={dropEffect}
canDrop={canDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
/>
) : favorite.type === 'tag' ? (
<ExplorerTagNode
@@ -314,8 +247,8 @@ const ExplorerFavoriteNode = ({
tagId={favorite.id}
location={childLocation}
onDrop={handleOnChildrenDrop}
dropEffect={dropEffect}
canDrop={canDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
/>
) : favorite.type === 'folder' ? (
<ExplorerFolderNode
@@ -323,8 +256,8 @@ const ExplorerFavoriteNode = ({
nodeId={favorite.id}
location={childLocation}
onDrop={handleOnChildrenDrop}
dropEffect={dropEffect}
canDrop={canDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
/>
) : (
<ExplorerCollectionNode
@@ -332,8 +265,8 @@ const ExplorerFavoriteNode = ({
collectionId={favorite.id}
location={childLocation}
onDrop={handleOnChildrenDrop}
dropEffect={dropEffect}
canDrop={canDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
/>
);
};

View File

@@ -1,12 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const draggedOverHighlight = style({
position: 'relative',
selectors: {
'&[data-dragged-over="true"]': {
background: cssVar('--affine-hover-color'),
borderRadius: '4px',
},
},
});

View File

@@ -0,0 +1,36 @@
import type { DropTargetOptions } from '@affine/component';
import { isOrganizeSupportType } from '@affine/core/modules/organize/constants';
import type { AffineDNDData } from '@affine/core/types/dnd';
import type { ExplorerTreeNodeDropEffect } from '../../tree';
export const organizeChildrenDropEffect: ExplorerTreeNodeDropEffect = data => {
if (
data.treeInstruction?.type === 'reorder-above' ||
data.treeInstruction?.type === 'reorder-below'
) {
if (data.source.data.entity?.type === 'folder') {
return 'move';
}
} else {
return; // not supported
}
return;
};
export const organizeEmptyDropEffect: ExplorerTreeNodeDropEffect = data => {
const sourceType = data.source.data.entity?.type;
if (sourceType && isOrganizeSupportType(sourceType)) {
return 'link';
}
return;
};
/**
* Check whether the data can be dropped on the empty state of the organize section
*/
export const organizeEmptyRootCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
data => {
const type = data.source.data.entity?.type;
return !!type && isOrganizeSupportType(type);
};

View File

@@ -1,31 +1,66 @@
import { Skeleton } from '@affine/component';
import {
AnimatedFolderIcon,
type DropTargetDropEvent,
Skeleton,
useDropTarget,
} from '@affine/component';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { FolderIcon } from '@blocksuite/icons/rc';
import { ExplorerEmptySection } from '../../layouts/empty-section';
import { DropEffect } from '../../tree';
import { organizeEmptyDropEffect, organizeEmptyRootCanDrop } from './dnd';
export const RootEmpty = ({
onClickCreate,
isLoading,
}: {
interface RootEmptyProps {
onClickCreate?: () => void;
isLoading?: boolean;
}) => {
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
}
export const RootEmptyLoading = () => {
return <Skeleton />;
};
export const RootEmptyReady = ({
onClickCreate,
onDrop,
}: Omit<RootEmptyProps, 'isLoading'>) => {
const t = useI18n();
if (isLoading) {
return <Skeleton />;
}
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
useDropTarget<AffineDNDData>(
() => ({
data: { at: 'explorer:organize:root' },
onDrop,
canDrop: organizeEmptyRootCanDrop,
}),
[onDrop]
);
return (
<ExplorerEmptySection
icon={FolderIcon}
ref={dropTargetRef}
icon={<AnimatedFolderIcon open={!!draggedOverDraggable} />}
message={t['com.affine.rootAppSidebar.organize.empty']()}
messageTestId="slider-bar-organize-empty-message"
actionText={t[
'com.affine.rootAppSidebar.organize.empty.new-folders-button'
]()}
onActionClick={onClickCreate}
/>
>
{draggedOverDraggable && (
<DropEffect
position={draggedOverPosition}
dropEffect={organizeEmptyDropEffect({
source: draggedOverDraggable,
treeInstruction: null,
})}
/>
)}
</ExplorerEmptySection>
);
};
export const RootEmpty = ({ isLoading, ...props }: RootEmptyProps) => {
return isLoading ? <RootEmptyLoading /> : <RootEmptyReady {...props} />;
};

View File

@@ -5,10 +5,7 @@ import {
toast,
} from '@affine/component';
import { track } from '@affine/core/mixpanel';
import {
type ExplorerTreeNodeDropEffect,
ExplorerTreeRoot,
} from '@affine/core/modules/explorer/views/tree';
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
import {
type FolderNode,
OrganizeService,
@@ -22,8 +19,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ExplorerService } from '../../../services/explorer';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerFolderNode } from '../../nodes/folder';
import { organizeChildrenDropEffect } from './dnd';
import { RootEmpty } from './empty';
import * as styles from './styles.css';
export const ExplorerOrganize = () => {
const { organizeService, explorerService } = useServices({
@@ -36,10 +33,11 @@ export const ExplorerOrganize = () => {
const t = useI18n();
const rootFolder = organizeService.folderTree.rootFolder;
const folderTree = organizeService.folderTree;
const rootFolder = folderTree.rootFolder;
const folders = useLiveData(rootFolder.sortedChildren$);
const isLoading = useLiveData(organizeService.folderTree.isLoading$);
const isLoading = useLiveData(folderTree.isLoading$);
const handleCreateFolder = useCallback(() => {
const newFolderId = rootFolder.createFolder(
@@ -49,6 +47,7 @@ export const ExplorerOrganize = () => {
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setNewFolderId(newFolderId);
explorerSection.setCollapsed(false);
return newFolderId;
}, [explorerSection, rootFolder]);
const handleOnChildrenDrop = useCallback(
@@ -78,21 +77,22 @@ export const ExplorerOrganize = () => {
[rootFolder, t]
);
const handleChildrenDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
data => {
if (
data.treeInstruction?.type === 'reorder-above' ||
data.treeInstruction?.type === 'reorder-below'
) {
if (data.source.data.entity?.type === 'folder') {
return 'move';
}
} else {
return; // not supported
}
return;
const createFolderAndDrop = useCallback(
(data: DropTargetDropEvent<AffineDNDData>) => {
const newFolderId = handleCreateFolder();
setNewFolderId(null);
const newFolder$ = folderTree.folderNode$(newFolderId);
const entity = data.source.data.entity;
if (!entity) return;
const { type, id } = entity;
if (type === 'folder') return;
const folder = newFolder$.value;
if (!folder) return;
folder.createLink(type, id, folder.indexAt('after'));
},
[]
[folderTree, handleCreateFolder]
);
const handleChildrenCanDrop = useMemo<
@@ -106,7 +106,6 @@ export const ExplorerOrganize = () => {
return (
<CollapsibleSection
name="organize"
headerClassName={styles.draggedOverHighlight}
title={t['com.affine.rootAppSidebar.organize']()}
actions={
<IconButton
@@ -123,7 +122,11 @@ export const ExplorerOrganize = () => {
>
<ExplorerTreeRoot
placeholder={
<RootEmpty onClickCreate={handleCreateFolder} isLoading={isLoading} />
<RootEmpty
onClickCreate={handleCreateFolder}
isLoading={isLoading}
onDrop={createFolderAndDrop}
/>
}
>
{folders.map(child => (
@@ -132,7 +135,7 @@ export const ExplorerOrganize = () => {
nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
onDrop={handleOnChildrenDrop}
dropEffect={handleChildrenDropEffect}
dropEffect={organizeChildrenDropEffect}
canDrop={handleChildrenCanDrop}
location={{
at: 'explorer:organize:folder-node',

View File

@@ -1,11 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const draggedOverHighlight = style({
selectors: {
'&[data-dragged-over="true"]': {
background: cssVar('--affine-hover-color'),
borderRadius: '4px',
},
},
});

View File

@@ -3,10 +3,9 @@ import { style } from '@vanilla-extract/css';
export const dropEffect = style({
zIndex: 99999,
position: 'absolute',
left: '0px',
top: '-34px',
opacity: 0.9,
position: 'fixed',
left: '10px',
top: '-20px',
background: cssVar('--affine-background-primary-color'),
boxShadow: cssVar('--affine-toolbar-shadow'),
padding: '0px 4px',

View File

@@ -1,5 +1,7 @@
import type { useDropTarget } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { CopyIcon, LinkIcon, MoveToIcon } from '@blocksuite/icons/rc';
import { createPortal } from 'react-dom';
import * as styles from './drop-effect.css';
@@ -8,15 +10,15 @@ export const DropEffect = ({
position,
}: {
dropEffect?: 'copy' | 'move' | 'link' | undefined;
position: { x: number; y: number };
position: ReturnType<typeof useDropTarget>['draggedOverPosition'];
}) => {
const t = useI18n();
if (dropEffect === undefined) return null;
return (
return createPortal(
<div
className={styles.dropEffect}
style={{
transform: `translate(${position.x + 10}px, ${position.y + 10}px)`,
transform: `translate(${position.clientX}px, ${position.clientY}px)`,
}}
>
{dropEffect === 'copy' ? (
@@ -31,6 +33,7 @@ export const DropEffect = ({
: dropEffect === 'move'
? t['com.affine.rootAppSidebar.explorer.drop-effect.move']()
: t['com.affine.rootAppSidebar.explorer.drop-effect.link']()}
</div>
</div>,
document.body
);
};

View File

@@ -47,6 +47,12 @@ export type ExplorerTreeNodeDropEffectData = {
export type ExplorerTreeNodeDropEffect = (
data: ExplorerTreeNodeDropEffectData
) => 'copy' | 'move' | 'link' | undefined;
export type ExplorerTreeNodeIcon = React.ComponentType<{
className?: string;
draggedOver?: boolean;
treeInstruction?: DropTargetTreeInstruction | null;
collapsed?: boolean;
}>;
export const ExplorerTreeNode = ({
children,
@@ -74,11 +80,7 @@ export const ExplorerTreeNode = ({
...otherProps
}: {
name?: string;
icon?: React.ComponentType<{
className?: string;
draggedOver?: boolean;
treeInstruction?: DropTargetTreeInstruction | null;
}>;
icon?: ExplorerTreeNodeIcon;
children?: React.ReactNode;
active?: boolean;
reorderable?: boolean;
@@ -311,6 +313,7 @@ export const ExplorerTreeNode = ({
className={styles.icon}
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
</div>
)}
@@ -398,10 +401,7 @@ export const ExplorerTreeNode = ({
source: draggedOverDraggable,
treeInstruction: treeInstruction,
})}
position={{
x: draggedOverPosition.relativeX,
y: draggedOverPosition.relativeY,
}}
position={draggedOverPosition}
/>
)}
</div>

View File

@@ -0,0 +1,12 @@
export const OrganizeSupportType = [
'folder',
'doc',
'collection',
'tag',
] as const;
export type OrganizeSupportType = 'folder' | 'doc' | 'collection' | 'tag';
export const isOrganizeSupportType = (
type: string
): type is OrganizeSupportType =>
OrganizeSupportType.includes(type as OrganizeSupportType);