fix(core): canDrop check for external (#9058)

This commit is contained in:
pengx17
2024-12-09 10:25:38 +00:00
parent 9365958a02
commit 814b4c9cb0
12 changed files with 141 additions and 55 deletions

View File

@@ -0,0 +1,13 @@
import { createContext } from 'react';
import type { DNDData, ExternalDataAdapter } from './types';
export const DNDContext = createContext<{
/**
* external data adapter.
* if this is provided, the drop target will handle external elements as well.
*
* @default undefined
*/
externalDataAdapter?: ExternalDataAdapter<DNDData>;
}>({});

View File

@@ -17,9 +17,10 @@ import {
type Instruction, type Instruction,
type ItemMode, type ItemMode,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { DNDData } from './types'; import { DNDContext } from './context';
import type { DNDData, ExternalDataAdapter } from './types';
export type DropTargetDropEvent<D extends DNDData> = { export type DropTargetDropEvent<D extends DNDData> = {
treeInstruction: Instruction | null; treeInstruction: Instruction | null;
@@ -58,25 +59,27 @@ type DropTargetGet<T, D extends DNDData> =
| T | T
| ((data: DropTargetGetFeedback<D>) => T); | ((data: DropTargetGetFeedback<D>) => T);
export type ExternalGetDataFeedbackArgs = Parameters< const isExternalDrag = <D extends DNDData>(
NonNullable<Parameters<typeof dropTargetForExternal>[0]['getData']> args: Pick<DropTargetGetFeedback<D>, 'source'>
>[0]; ) => {
return !args.source['data'];
export type ExternalDataAdapter<D extends DNDData> = ( };
args: ExternalGetDataFeedbackArgs
) => D['draggable'];
const getAdaptedEventArgs = < const getAdaptedEventArgs = <
D extends DNDData, D extends DNDData,
Args extends Pick<DropTargetGetFeedback<D>, 'source'>, Args extends Pick<DropTargetGetFeedback<D>, 'source'>,
>( >(
options: DropTargetOptions<D>, options: DropTargetOptions<D>,
args: Args args: Args,
isDropEvent = false
): Args => { ): Args => {
const data = const data =
!args.source['data'] && options.externalDataAdapter isExternalDrag(args) && options.externalDataAdapter
? // @ts-expect-error hack for external data adapter (source has no data field) ? options.externalDataAdapter(
options.externalDataAdapter(args as ExternalGetDataFeedbackArgs) // @ts-expect-error hack for external data adapter (source has no data field)
args as ExternalGetDataFeedbackArgs,
isDropEvent
)
: args.source['data']; : args.source['data'];
return { return {
@@ -167,11 +170,16 @@ export interface DropTargetOptions<D extends DNDData = DNDData> {
onDrag?: (data: DropTargetDragEvent<D>) => void; onDrag?: (data: DropTargetDragEvent<D>) => void;
/** /**
* external data adapter. * external data adapter.
* if this is provided, the drop target will handle external elements as well. * Will use the external data adapter from the context if not provided.
*/
externalDataAdapter?: ExternalDataAdapter<D>;
/**
* Make the drop target allow external data.
* If this is undefined, it will be set to true if externalDataAdapter is provided.
* *
* @default undefined * @default undefined
*/ */
externalDataAdapter?: ExternalDataAdapter<D>; allowExternal?: boolean;
} }
export const useDropTarget = <D extends DNDData = DNDData>( export const useDropTarget = <D extends DNDData = DNDData>(
@@ -205,19 +213,56 @@ export const useDropTarget = <D extends DNDData = DNDData>(
const enableDraggedOverPosition = useRef(false); const enableDraggedOverPosition = useRef(false);
const enableDropEffect = useRef(false); const enableDropEffect = useRef(false);
// eslint-disable-next-line react-hooks/exhaustive-deps const dropTargetContext = useContext(DNDContext);
const options = useMemo(getOptions, deps);
const options = useMemo(() => {
const opts = getOptions();
const allowExternal = opts.allowExternal ?? !!opts.externalDataAdapter;
return {
...opts,
allowExternal,
externalDataAdapter: allowExternal
? (opts.externalDataAdapter ??
(dropTargetContext.externalDataAdapter as ExternalDataAdapter<D>))
: undefined,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, dropTargetContext.externalDataAdapter]);
const dropTargetOptions = useMemo(() => { const dropTargetOptions = useMemo(() => {
const wrappedCanDrop = dropTargetGet(options.canDrop, options);
return { return {
get element() { get element() {
return dropTargetRef.current; return dropTargetRef.current;
}, },
canDrop: dropTargetGet(options.canDrop, options), canDrop: wrappedCanDrop
? (args: DropTargetGetFeedback<D>) => {
// check if args has data. if not, it's an external drag
// we always allow external drag since the data is only
// available in drop event
if (isExternalDrag(args) && options.externalDataAdapter) {
return true;
}
return wrappedCanDrop(args);
}
: undefined,
getDropEffect: dropTargetGet(options.dropEffect, options), getDropEffect: dropTargetGet(options.dropEffect, options),
getIsSticky: dropTargetGet(options.isSticky, options), getIsSticky: dropTargetGet(options.isSticky, options),
onDrop: (args: DropTargetDropEvent<D>) => { onDrop: (_args: DropTargetDropEvent<D>) => {
args = getAdaptedEventArgs(options, args); // external data is only available in drop event thus
// this is the only case for getAdaptedEventArgs
const args = getAdaptedEventArgs(options, _args, true);
if (
isExternalDrag(_args) &&
options.externalDataAdapter &&
typeof options.canDrop === 'function' &&
// there is a small flaw that canDrop called in onDrop misses
// `input and `element` arguments
!options.canDrop(args as any)
) {
return;
}
if (enableDraggedOver.current) { if (enableDraggedOver.current) {
setDraggedOver(false); setDraggedOver(false);
} }

View File

@@ -1,3 +1,4 @@
export * from './context';
export * from './draggable'; export * from './draggable';
export * from './drop-indicator'; export * from './drop-indicator';
export * from './drop-target'; export * from './drop-target';

View File

@@ -1,3 +1,5 @@
import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
export interface DNDData< export interface DNDData<
Draggable extends Record<string, unknown> = Record<string, unknown>, Draggable extends Record<string, unknown> = Record<string, unknown>,
DropTarget extends Record<string, unknown> = Record<string, unknown>, DropTarget extends Record<string, unknown> = Record<string, unknown>,
@@ -5,3 +7,12 @@ export interface DNDData<
draggable: Draggable; draggable: Draggable;
dropTarget: DropTarget; dropTarget: DropTarget;
} }
export type ExternalGetDataFeedbackArgs = Parameters<
NonNullable<Parameters<typeof dropTargetForExternal>[0]['getData']>
>[0];
export type ExternalDataAdapter<D extends DNDData> = (
args: ExternalGetDataFeedbackArgs,
isDropEvent?: boolean
) => D['draggable'];

View File

@@ -1,19 +1,22 @@
import { DNDContext } from '@affine/component';
import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout'; import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout';
import { workbenchRoutes } from '@affine/core/desktop/workbench-router'; import { workbenchRoutes } from '@affine/core/desktop/workbench-router';
import { import {
DefaultServerService, DefaultServerService,
WorkspaceServerService, WorkspaceServerService,
} from '@affine/core/modules/cloud'; } from '@affine/core/modules/cloud';
import { DndService } from '@affine/core/modules/dnd/services';
import { ZipTransformer } from '@blocksuite/affine/blocks'; import { ZipTransformer } from '@blocksuite/affine/blocks';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import { import {
FrameworkScope, FrameworkScope,
GlobalContextService, GlobalContextService,
useLiveData, useLiveData,
useService,
useServices, useServices,
WorkspacesService, WorkspacesService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import type { ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { matchPath, useLocation, useParams } from 'react-router-dom'; import { matchPath, useLocation, useParams } from 'react-router-dom';
@@ -128,6 +131,18 @@ export const Component = (): ReactElement => {
return <WorkspacePage meta={meta} />; return <WorkspacePage meta={meta} />;
}; };
const DNDContextProvider = ({ children }: PropsWithChildren) => {
const dndService = useService(DndService);
const contextValue = useMemo(() => {
return {
externalDataAdapter: dndService.externalDataAdapter,
};
}, [dndService.externalDataAdapter]);
return (
<DNDContext.Provider value={contextValue}>{children}</DNDContext.Provider>
);
};
const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => { const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
const { workspacesService, globalContextService, defaultServerService } = const { workspacesService, globalContextService, defaultServerService } =
useServices({ useServices({
@@ -229,7 +244,9 @@ const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
return ( return (
<FrameworkScope scope={workspaceServer?.scope}> <FrameworkScope scope={workspaceServer?.scope}>
<FrameworkScope scope={workspace.scope}> <FrameworkScope scope={workspace.scope}>
<AppContainer fallback /> <DNDContextProvider>
<AppContainer fallback />
</DNDContextProvider>
</FrameworkScope> </FrameworkScope>
</FrameworkScope> </FrameworkScope>
); );
@@ -238,11 +255,13 @@ const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
return ( return (
<FrameworkScope scope={workspaceServer?.scope}> <FrameworkScope scope={workspaceServer?.scope}>
<FrameworkScope scope={workspace.scope}> <FrameworkScope scope={workspace.scope}>
<AffineErrorBoundary height="100vh"> <DNDContextProvider>
<WorkspaceLayout> <AffineErrorBoundary height="100vh">
<WorkbenchRoot /> <WorkspaceLayout>
</WorkspaceLayout> <WorkbenchRoot />
</AffineErrorBoundary> </WorkspaceLayout>
</AffineErrorBoundary>
</DNDContextProvider>
</FrameworkScope> </FrameworkScope>
</FrameworkScope> </FrameworkScope>
); );

View File

@@ -1,4 +1,7 @@
import type { ExternalGetDataFeedbackArgs } from '@affine/component'; import type {
ExternalDataAdapter,
ExternalGetDataFeedbackArgs,
} from '@affine/component';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import type { DocsService, WorkspaceService } from '@toeverything/infra'; import type { DocsService, WorkspaceService } from '@toeverything/infra';
import { Service } from '@toeverything/infra'; import { Service } from '@toeverything/infra';
@@ -23,7 +26,13 @@ export class DndService extends Service {
private readonly resolvers = new Map<string, EntityResolver>(); private readonly resolvers = new Map<string, EntityResolver>();
externalDataAdapter = (args: ExternalGetDataFeedbackArgs) => { externalDataAdapter: ExternalDataAdapter<AffineDNDData> = (
args: ExternalGetDataFeedbackArgs,
isDropEvent?: boolean
) => {
if (!isDropEvent) {
return {};
}
const from: AffineDNDData['draggable']['from'] = { const from: AffineDNDData['draggable']['from'] = {
at: 'external', at: 'external',
}; };
@@ -43,6 +52,10 @@ export class DndService extends Service {
} }
} }
if (!entity) {
return {}; // no resolver can handle this data
}
return { return {
from, from,
entity, entity,

View File

@@ -189,10 +189,9 @@ export const ExplorerCollectionNode = ({
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>( const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
() => args => { () => args => {
const entityType = args.source.data.entity?.type; const entityType = args.source.data.entity?.type;
const isExternalDrop = args.source.data.from?.at === 'external';
return args.treeInstruction?.type !== 'make-child' return args.treeInstruction?.type !== 'make-child'
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true) ? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
: entityType === 'doc' || isExternalDrop; : entityType === 'doc';
}, },
[canDrop] [canDrop]
); );

View File

@@ -180,10 +180,9 @@ export const ExplorerDocNode = ({
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>( const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
() => args => { () => args => {
const entityType = args.source.data.entity?.type; const entityType = args.source.data.entity?.type;
const isExternalDrop = args.source.data.from?.at === 'external';
return args.treeInstruction?.type !== 'make-child' return args.treeInstruction?.type !== 'make-child'
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true) ? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
: entityType === 'doc' || isExternalDrop; : entityType === 'doc';
}, },
[canDrop] [canDrop]
); );

View File

@@ -16,10 +16,8 @@ export const favoriteChildrenDropEffect: ExplorerTreeNodeDropEffect = data => {
) { ) {
return 'move'; return 'move';
} else if ( } else if (
(data.source.data.entity?.type && data.source.data.entity?.type &&
isFavoriteSupportType(data.source.data.entity.type)) || isFavoriteSupportType(data.source.data.entity.type)
// always allow external drop
data.source.data.from?.at === 'external'
) { ) {
return 'link'; return 'link';
} }
@@ -39,7 +37,7 @@ export const favoriteRootCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
data => { data => {
return data.source.data.entity?.type return data.source.data.entity?.type
? isFavoriteSupportType(data.source.data.entity.type) ? isFavoriteSupportType(data.source.data.entity.type)
: data.source.data.from?.at === 'external'; // always allow external drop : false;
}; };
export const favoriteChildrenCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] = export const favoriteChildrenCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =

View File

@@ -3,11 +3,9 @@ import {
Skeleton, Skeleton,
useDropTarget, useDropTarget,
} from '@affine/component'; } from '@affine/component';
import { DndService } from '@affine/core/modules/dnd/services';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { FavoriteIcon } from '@blocksuite/icons/rc'; import { FavoriteIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { ExplorerEmptySection } from '../../layouts/empty-section'; import { ExplorerEmptySection } from '../../layouts/empty-section';
import { DropEffect } from '../../tree'; import { DropEffect } from '../../tree';
@@ -23,7 +21,6 @@ const RootEmptyLoading = () => {
}; };
const RootEmptyReady = ({ onDrop }: Omit<RootEmptyProps, 'isLoading'>) => { const RootEmptyReady = ({ onDrop }: Omit<RootEmptyProps, 'isLoading'>) => {
const t = useI18n(); const t = useI18n();
const dndService = useService(DndService);
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } = const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
useDropTarget<AffineDNDData>( useDropTarget<AffineDNDData>(
@@ -33,9 +30,9 @@ const RootEmptyReady = ({ onDrop }: Omit<RootEmptyProps, 'isLoading'>) => {
}, },
onDrop: onDrop, onDrop: onDrop,
canDrop: favoriteRootCanDrop, canDrop: favoriteRootCanDrop,
externalDataAdapter: dndService.externalDataAdapter, allowExternal: true,
}), }),
[dndService.externalDataAdapter, onDrop] [onDrop]
); );
return ( return (

View File

@@ -4,7 +4,6 @@ import {
useDropTarget, useDropTarget,
} from '@affine/component'; } from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { DndService } from '@affine/core/modules/dnd/services';
import { import {
DropEffect, DropEffect,
ExplorerTreeRoot, ExplorerTreeRoot,
@@ -21,7 +20,6 @@ import { track } from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc'; import { PlusIcon } from '@blocksuite/icons/rc';
import { import {
useLiveData, useLiveData,
useService,
useServices, useServices,
WorkspaceService, WorkspaceService,
} from '@toeverything/infra'; } from '@toeverything/infra';
@@ -151,8 +149,6 @@ export const ExplorerFavorites = () => {
[favoriteService] [favoriteService]
); );
const dndService = useService(DndService);
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } = const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
useDropTarget<AffineDNDData>( useDropTarget<AffineDNDData>(
() => ({ () => ({
@@ -161,9 +157,9 @@ export const ExplorerFavorites = () => {
}, },
onDrop: handleDrop, onDrop: handleDrop,
canDrop: favoriteRootCanDrop, canDrop: favoriteRootCanDrop,
externalDataAdapter: dndService.externalDataAdapter, allowExternal: true,
}), }),
[dndService.externalDataAdapter, handleDrop] [handleDrop]
); );
return ( return (

View File

@@ -11,7 +11,6 @@ import {
} from '@affine/component'; } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal'; import { RenameModal } from '@affine/component/rename-modal';
import { AppSidebarService } from '@affine/core/modules/app-sidebar'; import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import { DndService } from '@affine/core/modules/dnd/services';
import { WorkbenchLink } from '@affine/core/modules/workbench'; import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import { extractEmojiIcon } from '@affine/core/utils'; import { extractEmojiIcon } from '@affine/core/utils';
@@ -186,7 +185,6 @@ export const ExplorerTreeNode = ({
}, },
[canDrop, reorderable] [canDrop, reorderable]
); );
const dndService = useService(DndService);
const { const {
dropTargetRef, dropTargetRef,
@@ -224,9 +222,7 @@ export const ExplorerTreeNode = ({
} }
}, },
canDrop: handleCanDrop, canDrop: handleCanDrop,
externalDataAdapter(args) { allowExternal: true,
return dndService.externalDataAdapter(args) as any;
},
}), }),
[ [
dndData?.dropTarget, dndData?.dropTarget,
@@ -238,7 +234,6 @@ export const ExplorerTreeNode = ({
cid, cid,
onDrop, onDrop,
setCollapsed, setCollapsed,
dndService,
] ]
); );
const isSelfDraggedOver = draggedOverDraggable?.data.__cid === cid; const isSelfDraggedOver = draggedOverDraggable?.data.__cid === cid;