mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -2,7 +2,7 @@
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 0,
|
||||
"op": 89,
|
||||
"op": 6,
|
||||
"w": 240,
|
||||
"h": 240,
|
||||
"nm": "folder",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
packages/frontend/core/src/modules/organize/constants.ts
Normal file
12
packages/frontend/core/src/modules/organize/constants.ts
Normal 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);
|
||||
Reference in New Issue
Block a user