mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-21 04:41:29 +03:00
feat(core): allow bs snapshot dragging targets (#9093)
fix AF-1924, AF-1848, AF-1928, AF-1931 dnd between affine & editor <div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/dff3ceb1-dc82-4222-9b55-13be80b28b2f.mp4"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/dff3ceb1-dc82-4222-9b55-13be80b28b2f.mp4"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/dff3ceb1-dc82-4222-9b55-13be80b28b2f.mp4">20241210-1217-49.8960381.mp4</video>
This commit is contained in:
parent
331e674e8b
commit
dc7d128252
@ -171,6 +171,7 @@ export class WebContentViewsManager {
|
|||||||
ready: ready.has(w.id),
|
ready: ready.has(w.id),
|
||||||
activeViewIndex: w.activeViewIndex,
|
activeViewIndex: w.activeViewIndex,
|
||||||
views: w.views,
|
views: w.views,
|
||||||
|
basename: w.basename,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@ -952,6 +953,7 @@ export const onTabsStatusChange = (
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
activeViewIndex: number;
|
activeViewIndex: number;
|
||||||
views: WorkbenchViewMeta[];
|
views: WorkbenchViewMeta[];
|
||||||
|
basename: string;
|
||||||
}[]
|
}[]
|
||||||
) => void
|
) => void
|
||||||
) => {
|
) => {
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
import type { DNDData, ExternalDataAdapter } from './types';
|
import type { DNDData, fromExternalData, toExternalData } from './types';
|
||||||
|
|
||||||
export const DNDContext = createContext<{
|
export const DNDContext = createContext<{
|
||||||
/**
|
/**
|
||||||
* external data adapter.
|
* external data adapter.
|
||||||
|
* Convert the external data to the draggable data that are known to affine.
|
||||||
|
*
|
||||||
* if this is provided, the drop target will handle external elements as well.
|
* if this is provided, the drop target will handle external elements as well.
|
||||||
*
|
*
|
||||||
* @default undefined
|
* @default undefined
|
||||||
*/
|
*/
|
||||||
externalDataAdapter?: ExternalDataAdapter<DNDData>;
|
fromExternalData?: fromExternalData<DNDData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the draggable data to the external data.
|
||||||
|
* Mainly used to be consumed by blocksuite.
|
||||||
|
*
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
toExternalData?: toExternalData<DNDData>;
|
||||||
}>({});
|
}>({});
|
||||||
|
@ -92,7 +92,7 @@ export const DropTarget: StoryFn<{ canDrop: boolean }> = ({ canDrop }) => {
|
|||||||
onDrop(data) {
|
onDrop(data) {
|
||||||
setDropData(prev => prev + data.source.data.text);
|
setDropData(prev => prev + data.source.data.text);
|
||||||
},
|
},
|
||||||
externalDataAdapter(args) {
|
fromExternalData(args) {
|
||||||
return {
|
return {
|
||||||
text: args.source.getStringData(args.source.types[0]) || 'no value',
|
text: args.source.getStringData(args.source.types[0]) || 'no value',
|
||||||
};
|
};
|
||||||
|
@ -5,42 +5,21 @@ import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/eleme
|
|||||||
import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
|
import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
|
||||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
||||||
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/types';
|
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import ReactDOM, { flushSync } from 'react-dom';
|
import ReactDOM, { flushSync } from 'react-dom';
|
||||||
|
|
||||||
import type { DNDData } from './types';
|
import { DNDContext } from './context';
|
||||||
|
import {
|
||||||
type DraggableGetFeedback = Parameters<
|
type DNDData,
|
||||||
NonNullable<Parameters<typeof draggable>[0]['getInitialData']>
|
type DraggableGet,
|
||||||
>[0];
|
draggableGet,
|
||||||
|
type DraggableGetFeedback,
|
||||||
type DraggableGet<T> = T | ((data: DraggableGetFeedback) => T);
|
type toExternalData,
|
||||||
|
} from './types';
|
||||||
function draggableGet<T>(
|
|
||||||
get: T
|
|
||||||
): T extends undefined
|
|
||||||
? undefined
|
|
||||||
: T extends DraggableGet<infer I>
|
|
||||||
? (args: DraggableGetFeedback) => I
|
|
||||||
: never {
|
|
||||||
if (get === undefined) {
|
|
||||||
return undefined as any;
|
|
||||||
}
|
|
||||||
return ((args: DraggableGetFeedback) =>
|
|
||||||
typeof get === 'function' ? (get as any)(args) : get) as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DraggableOptions<D extends DNDData = DNDData> {
|
export interface DraggableOptions<D extends DNDData = DNDData> {
|
||||||
data?: DraggableGet<D['draggable']>;
|
data?: DraggableGet<D['draggable']>;
|
||||||
dataForExternal?: DraggableGet<{
|
toExternalData?: toExternalData<D>;
|
||||||
[Key in
|
|
||||||
| 'text/uri-list'
|
|
||||||
| 'text/plain'
|
|
||||||
| 'text/html'
|
|
||||||
| 'Files'
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
| (string & {})]?: string;
|
|
||||||
}>;
|
|
||||||
canDrag?: DraggableGet<boolean>;
|
canDrag?: DraggableGet<boolean>;
|
||||||
disableDragPreview?: boolean;
|
disableDragPreview?: boolean;
|
||||||
dragPreviewPosition?: DraggableDragPreviewPosition;
|
dragPreviewPosition?: DraggableDragPreviewPosition;
|
||||||
@ -82,8 +61,23 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
|||||||
const enableDropTarget = useRef(false);
|
const enableDropTarget = useRef(false);
|
||||||
const enableDragging = useRef(false);
|
const enableDragging = useRef(false);
|
||||||
|
|
||||||
|
const context = useContext(DNDContext);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const options = useMemo(getOptions, deps);
|
const options = useMemo(() => {
|
||||||
|
const opts = getOptions();
|
||||||
|
|
||||||
|
const toExternalData = opts.toExternalData ?? context.toExternalData;
|
||||||
|
return {
|
||||||
|
...opts,
|
||||||
|
toExternalData: toExternalData
|
||||||
|
? (args: DraggableGetFeedback) => {
|
||||||
|
return (opts.toExternalData ?? toExternalData)(args, opts.data);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [...deps, context.toExternalData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dragRef.current) {
|
if (!dragRef.current) {
|
||||||
@ -110,7 +104,7 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
|||||||
dragHandle: dragHandleRef.current ?? undefined,
|
dragHandle: dragHandleRef.current ?? undefined,
|
||||||
canDrag: draggableGet(options.canDrag),
|
canDrag: draggableGet(options.canDrag),
|
||||||
getInitialData: draggableGet(options.data),
|
getInitialData: draggableGet(options.data),
|
||||||
getInitialDataForExternal: draggableGet(options.dataForExternal),
|
getInitialDataForExternal: draggableGet(options.toExternalData),
|
||||||
onDragStart: args => {
|
onDragStart: args => {
|
||||||
if (enableDragging.current) {
|
if (enableDragging.current) {
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
|
@ -20,7 +20,7 @@ import {
|
|||||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { DNDContext } from './context';
|
import { DNDContext } from './context';
|
||||||
import type { DNDData, ExternalDataAdapter } from './types';
|
import type { DNDData, fromExternalData } from './types';
|
||||||
|
|
||||||
export type DropTargetDropEvent<D extends DNDData> = {
|
export type DropTargetDropEvent<D extends DNDData> = {
|
||||||
treeInstruction: Instruction | null;
|
treeInstruction: Instruction | null;
|
||||||
@ -74,8 +74,8 @@ const getAdaptedEventArgs = <
|
|||||||
isDropEvent = false
|
isDropEvent = false
|
||||||
): Args => {
|
): Args => {
|
||||||
const data =
|
const data =
|
||||||
isExternalDrag(args) && options.externalDataAdapter
|
isExternalDrag(args) && options.fromExternalData
|
||||||
? options.externalDataAdapter(
|
? options.fromExternalData(
|
||||||
// @ts-expect-error hack for external data adapter (source has no data field)
|
// @ts-expect-error hack for external data adapter (source has no data field)
|
||||||
args as ExternalGetDataFeedbackArgs,
|
args as ExternalGetDataFeedbackArgs,
|
||||||
isDropEvent
|
isDropEvent
|
||||||
@ -172,10 +172,10 @@ export interface DropTargetOptions<D extends DNDData = DNDData> {
|
|||||||
* external data adapter.
|
* external data adapter.
|
||||||
* Will use the external data adapter from the context if not provided.
|
* Will use the external data adapter from the context if not provided.
|
||||||
*/
|
*/
|
||||||
externalDataAdapter?: ExternalDataAdapter<D>;
|
fromExternalData?: fromExternalData<D>;
|
||||||
/**
|
/**
|
||||||
* Make the drop target allow external data.
|
* Make the drop target allow external data.
|
||||||
* If this is undefined, it will be set to true if externalDataAdapter is provided.
|
* If this is undefined, it will be set to true if fromExternalData is provided.
|
||||||
*
|
*
|
||||||
* @default undefined
|
* @default undefined
|
||||||
*/
|
*/
|
||||||
@ -217,17 +217,17 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const opts = getOptions();
|
const opts = getOptions();
|
||||||
const allowExternal = opts.allowExternal ?? !!opts.externalDataAdapter;
|
const allowExternal = opts.allowExternal ?? !!opts.fromExternalData;
|
||||||
return {
|
return {
|
||||||
...opts,
|
...opts,
|
||||||
allowExternal,
|
allowExternal,
|
||||||
externalDataAdapter: allowExternal
|
fromExternalData: allowExternal
|
||||||
? (opts.externalDataAdapter ??
|
? (opts.fromExternalData ??
|
||||||
(dropTargetContext.externalDataAdapter as ExternalDataAdapter<D>))
|
(dropTargetContext.fromExternalData as fromExternalData<D>))
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [...deps, dropTargetContext.externalDataAdapter]);
|
}, [...deps, dropTargetContext.fromExternalData]);
|
||||||
|
|
||||||
const dropTargetOptions = useMemo(() => {
|
const dropTargetOptions = useMemo(() => {
|
||||||
const wrappedCanDrop = dropTargetGet(options.canDrop, options);
|
const wrappedCanDrop = dropTargetGet(options.canDrop, options);
|
||||||
@ -240,7 +240,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
// check if args has data. if not, it's an external drag
|
// check if args has data. if not, it's an external drag
|
||||||
// we always allow external drag since the data is only
|
// we always allow external drag since the data is only
|
||||||
// available in drop event
|
// available in drop event
|
||||||
if (isExternalDrag(args) && options.externalDataAdapter) {
|
if (isExternalDrag(args) && options.fromExternalData) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return wrappedCanDrop(args);
|
return wrappedCanDrop(args);
|
||||||
@ -249,20 +249,6 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
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>) => {
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
@ -292,6 +278,21 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
if (dropTargetRef.current) {
|
if (dropTargetRef.current) {
|
||||||
delete dropTargetRef.current.dataset['draggedOver'];
|
delete dropTargetRef.current.dataset['draggedOver'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.fromExternalData &&
|
||||||
|
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 (
|
if (
|
||||||
args.location.current.dropTargets[0]?.element ===
|
args.location.current.dropTargets[0]?.element ===
|
||||||
dropTargetRef.current
|
dropTargetRef.current
|
||||||
@ -451,11 +452,11 @@ export const useDropTarget = <D extends DNDData = DNDData>(
|
|||||||
}, [dropTargetOptions]);
|
}, [dropTargetOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dropTargetRef.current || !options.externalDataAdapter) {
|
if (!dropTargetRef.current || !options.fromExternalData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return dropTargetForExternal(dropTargetOptions as any);
|
return dropTargetForExternal(dropTargetOptions as any);
|
||||||
}, [dropTargetOptions, options.externalDataAdapter]);
|
}, [dropTargetOptions, options.fromExternalData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dropTargetRef,
|
dropTargetRef,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
|
import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
|
||||||
|
|
||||||
export interface DNDData<
|
export interface DNDData<
|
||||||
@ -12,7 +13,44 @@ export type ExternalGetDataFeedbackArgs = Parameters<
|
|||||||
NonNullable<Parameters<typeof dropTargetForExternal>[0]['getData']>
|
NonNullable<Parameters<typeof dropTargetForExternal>[0]['getData']>
|
||||||
>[0];
|
>[0];
|
||||||
|
|
||||||
export type ExternalDataAdapter<D extends DNDData> = (
|
export type fromExternalData<D extends DNDData> = (
|
||||||
args: ExternalGetDataFeedbackArgs,
|
args: ExternalGetDataFeedbackArgs,
|
||||||
isDropEvent?: boolean
|
isDropEvent?: boolean
|
||||||
) => D['draggable'];
|
) => D['draggable'];
|
||||||
|
|
||||||
|
export type DraggableGetFeedback = Parameters<
|
||||||
|
NonNullable<Parameters<typeof draggable>[0]['getInitialData']>
|
||||||
|
>[0];
|
||||||
|
|
||||||
|
type DraggableGetFeedbackArgs = Parameters<
|
||||||
|
NonNullable<Parameters<typeof draggable>[0]['getInitialData']>
|
||||||
|
>[0];
|
||||||
|
|
||||||
|
export function draggableGet<T>(
|
||||||
|
get: T
|
||||||
|
): T extends undefined
|
||||||
|
? undefined
|
||||||
|
: T extends DraggableGet<infer I>
|
||||||
|
? (args: DraggableGetFeedback) => I
|
||||||
|
: never {
|
||||||
|
if (get === undefined) {
|
||||||
|
return undefined as any;
|
||||||
|
}
|
||||||
|
return ((args: DraggableGetFeedback) =>
|
||||||
|
typeof get === 'function' ? (get as any)(args) : get) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraggableGet<T> = T | ((data: DraggableGetFeedback) => T);
|
||||||
|
|
||||||
|
export type toExternalData<D extends DNDData> = (
|
||||||
|
args: DraggableGetFeedbackArgs,
|
||||||
|
data?: DraggableGet<D['draggable']>
|
||||||
|
) => {
|
||||||
|
[Key in
|
||||||
|
| 'text/uri-list'
|
||||||
|
| 'text/plain'
|
||||||
|
| 'text/html'
|
||||||
|
| 'Files'
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
| (string & {})]?: string;
|
||||||
|
};
|
||||||
|
@ -45,3 +45,8 @@ export const dragHandle = style({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const dragPreview = style({
|
||||||
|
// see https://atlassian.design/components/pragmatic-drag-and-drop/web-platform-design-constraints/#native-drag-previews
|
||||||
|
maxWidth: '280px',
|
||||||
|
});
|
||||||
|
@ -174,7 +174,7 @@ export function DetailPageHeader(
|
|||||||
docId: page.id,
|
docId: page.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dragRef, dragHandleRef, dragging } =
|
const { dragRef, dragging, CustomDragPreview } =
|
||||||
useDraggable<AffineDNDData>(() => {
|
useDraggable<AffineDNDData>(() => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@ -187,7 +187,7 @@ export function DetailPageHeader(
|
|||||||
id: page.id,
|
id: page.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
disableDragPreview: true,
|
dragPreviewPosition: 'pointer-outside',
|
||||||
};
|
};
|
||||||
}, [page.id]);
|
}, [page.id]);
|
||||||
|
|
||||||
@ -203,13 +203,14 @@ export function DetailPageHeader(
|
|||||||
}, [dragging, onDragging]);
|
}, [dragging, onDragging]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root} ref={dragRef} data-dragging={dragging}>
|
<>
|
||||||
<DragHandle
|
<div className={styles.root} ref={dragRef} data-dragging={dragging}>
|
||||||
ref={dragHandleRef}
|
<DragHandle dragging={dragging} className={styles.dragHandle} />
|
||||||
dragging={dragging}
|
{inner}
|
||||||
className={styles.dragHandle}
|
</div>
|
||||||
/>
|
<CustomDragPreview>
|
||||||
{inner}
|
<div className={styles.dragPreview}>{inner}</div>
|
||||||
</div>
|
</CustomDragPreview>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -135,9 +135,10 @@ const DNDContextProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const dndService = useService(DndService);
|
const dndService = useService(DndService);
|
||||||
const contextValue = useMemo(() => {
|
const contextValue = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
externalDataAdapter: dndService.externalDataAdapter,
|
fromExternalData: dndService.fromExternalData,
|
||||||
|
toExternalData: dndService.toExternalData,
|
||||||
};
|
};
|
||||||
}, [dndService.externalDataAdapter]);
|
}, [dndService.fromExternalData, dndService.toExternalData]);
|
||||||
return (
|
return (
|
||||||
<DNDContext.Provider value={contextValue}>{children}</DNDContext.Provider>
|
<DNDContext.Provider value={contextValue}>{children}</DNDContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
|
|
||||||
import { AppSidebarService } from '../../app-sidebar';
|
import { AppSidebarService } from '../../app-sidebar';
|
||||||
import { DesktopApiService } from '../../desktop-api';
|
import { DesktopApiService } from '../../desktop-api';
|
||||||
|
import { resolveLinkToDoc } from '../../navigation';
|
||||||
import { iconNameToIcon } from '../../workbench/constants';
|
import { iconNameToIcon } from '../../workbench/constants';
|
||||||
import { DesktopStateSynchronizer } from '../../workbench/services/desktop-state-synchronizer';
|
import { DesktopStateSynchronizer } from '../../workbench/services/desktop-state-synchronizer';
|
||||||
import {
|
import {
|
||||||
@ -176,23 +177,50 @@ const WorkbenchTab = ({
|
|||||||
dropEffect: 'move',
|
dropEffect: 'move',
|
||||||
canDrop: tabCanDrop(workbench),
|
canDrop: tabCanDrop(workbench),
|
||||||
isSticky: true,
|
isSticky: true,
|
||||||
|
allowExternal: true,
|
||||||
}),
|
}),
|
||||||
[onDrop, workbench]
|
[onDrop, workbench]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dragRef } = useDraggable<AffineDNDData>(
|
const { dragRef } = useDraggable<AffineDNDData>(() => {
|
||||||
() => ({
|
const urls = workbench.views.map(view => {
|
||||||
|
const url = new URL(
|
||||||
|
workbench.basename + (view.path?.pathname ?? ''),
|
||||||
|
location.origin
|
||||||
|
);
|
||||||
|
url.search = view.path?.search ?? '';
|
||||||
|
return url.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
let entity: AffineDNDData['draggable']['entity'];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const maybeDocLink = resolveLinkToDoc(url);
|
||||||
|
if (maybeDocLink && maybeDocLink.docId) {
|
||||||
|
entity = {
|
||||||
|
type: 'doc',
|
||||||
|
id: maybeDocLink.docId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
canDrag: dnd,
|
canDrag: dnd,
|
||||||
data: {
|
data: {
|
||||||
from: {
|
from: {
|
||||||
at: 'app-header:tabs',
|
at: 'app-header:tabs',
|
||||||
tabId: workbench.id,
|
tabId: workbench.id,
|
||||||
},
|
},
|
||||||
|
entity,
|
||||||
},
|
},
|
||||||
dragPreviewPosition: 'pointer-outside',
|
dragPreviewPosition: 'pointer-outside',
|
||||||
}),
|
toExternalData: () => {
|
||||||
[dnd, workbench.id]
|
return {
|
||||||
);
|
'text/uri-list': urls.join('\n'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [dnd, workbench.basename, workbench.id, workbench.views]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -1,16 +1,26 @@
|
|||||||
import type {
|
import {
|
||||||
ExternalDataAdapter,
|
type ExternalGetDataFeedbackArgs,
|
||||||
ExternalGetDataFeedbackArgs,
|
type fromExternalData,
|
||||||
|
type toExternalData,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
|
import { createPageModeSpecs } from '@affine/core/components/blocksuite/block-suite-editor/specs/page';
|
||||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { BlockStdScope } from '@blocksuite/affine/block-std';
|
||||||
|
import { DndApiExtensionIdentifier } from '@blocksuite/affine/blocks';
|
||||||
|
import {
|
||||||
|
DocCollection,
|
||||||
|
nanoid,
|
||||||
|
type SliceSnapshot,
|
||||||
|
} from '@blocksuite/affine/store';
|
||||||
import type { DocsService, WorkspaceService } from '@toeverything/infra';
|
import type { DocsService, WorkspaceService } from '@toeverything/infra';
|
||||||
import { Service } from '@toeverything/infra';
|
import { getAFFiNEWorkspaceSchema, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { resolveLinkToDoc } from '../../navigation';
|
import { resolveLinkToDoc } from '../../navigation';
|
||||||
|
|
||||||
type EntityResolver = (
|
type Entity = AffineDNDData['draggable']['entity'];
|
||||||
data: string
|
type EntityResolver = (data: string) => Entity | null;
|
||||||
) => AffineDNDData['draggable']['entity'] | null;
|
|
||||||
|
type ExternalDragPayload = ExternalGetDataFeedbackArgs['source'];
|
||||||
|
|
||||||
export class DndService extends Service {
|
export class DndService extends Service {
|
||||||
constructor(
|
constructor(
|
||||||
@ -20,13 +30,47 @@ export class DndService extends Service {
|
|||||||
super();
|
super();
|
||||||
|
|
||||||
// order matters
|
// order matters
|
||||||
this.resolvers.set('text/html', this.resolveHTML);
|
this.resolvers.push(this.resolveBlocksuiteExternalData);
|
||||||
this.resolvers.set('text/uri-list', this.resolveUriList);
|
|
||||||
|
const mimeResolvers: [string, EntityResolver][] = [
|
||||||
|
['text/html', this.resolveHTML],
|
||||||
|
['text/uri-list', this.resolveUriList],
|
||||||
|
];
|
||||||
|
|
||||||
|
mimeResolvers.forEach(([type, resolver]) => {
|
||||||
|
this.resolvers.push((source: ExternalDragPayload) => {
|
||||||
|
if (source.types.includes(type)) {
|
||||||
|
const stringData = source.getStringData(type);
|
||||||
|
if (stringData) {
|
||||||
|
return resolver(stringData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly resolvers = new Map<string, EntityResolver>();
|
private readonly resolvers: ((
|
||||||
|
source: ExternalDragPayload
|
||||||
|
) => Entity | null)[] = [];
|
||||||
|
|
||||||
externalDataAdapter: ExternalDataAdapter<AffineDNDData> = (
|
readonly blocksuiteDndAPI = (() => {
|
||||||
|
const collection = new DocCollection({
|
||||||
|
schema: getAFFiNEWorkspaceSchema(),
|
||||||
|
});
|
||||||
|
collection.meta.initialize();
|
||||||
|
const doc = collection.createDoc();
|
||||||
|
const std = new BlockStdScope({
|
||||||
|
doc,
|
||||||
|
extensions: createPageModeSpecs(this.framework),
|
||||||
|
});
|
||||||
|
this.disposables.push(() => {
|
||||||
|
collection.dispose();
|
||||||
|
});
|
||||||
|
return std.get(DndApiExtensionIdentifier);
|
||||||
|
})();
|
||||||
|
|
||||||
|
fromExternalData: fromExternalData<AffineDNDData> = (
|
||||||
args: ExternalGetDataFeedbackArgs,
|
args: ExternalGetDataFeedbackArgs,
|
||||||
isDropEvent?: boolean
|
isDropEvent?: boolean
|
||||||
) => {
|
) => {
|
||||||
@ -36,19 +80,15 @@ export class DndService extends Service {
|
|||||||
const from: AffineDNDData['draggable']['from'] = {
|
const from: AffineDNDData['draggable']['from'] = {
|
||||||
at: 'external',
|
at: 'external',
|
||||||
};
|
};
|
||||||
let entity: AffineDNDData['draggable']['entity'];
|
|
||||||
|
let entity: Entity | null = null;
|
||||||
|
|
||||||
// in the order of the resolvers instead of the order of the types
|
// in the order of the resolvers instead of the order of the types
|
||||||
for (const [type, resolver] of this.resolvers) {
|
for (const resolver of this.resolvers) {
|
||||||
if (args.source.types.includes(type)) {
|
const candidate = resolver(args.source);
|
||||||
const stringData = args.source.getStringData(type);
|
if (candidate) {
|
||||||
if (stringData) {
|
entity = candidate;
|
||||||
const candidate = resolver(stringData);
|
break;
|
||||||
if (candidate) {
|
|
||||||
entity = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +102,47 @@ export class DndService extends Service {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
toExternalData: toExternalData<AffineDNDData> = (args, data) => {
|
||||||
|
const normalData = typeof data === 'function' ? data(args) : data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!normalData ||
|
||||||
|
!normalData.entity ||
|
||||||
|
normalData.entity.type !== 'doc' ||
|
||||||
|
!normalData.entity.id
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: use blocksuite provided api to generate snapshot
|
||||||
|
const snapshotSlice: SliceSnapshot = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
children: [],
|
||||||
|
flavour: 'affine:embed-linked-doc',
|
||||||
|
type: 'block',
|
||||||
|
id: nanoid(),
|
||||||
|
props: {
|
||||||
|
pageId: normalData.entity.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'slice',
|
||||||
|
pageId: nanoid(),
|
||||||
|
pageVersion: 1,
|
||||||
|
workspaceId: this.workspaceService.workspace.id,
|
||||||
|
workspaceVersion: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(snapshotSlice);
|
||||||
|
|
||||||
|
const html = `<div data-blocksuite-snapshot="${encodeURIComponent(serialized)}"></div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'text/html': html,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
private readonly resolveUriList: EntityResolver = urls => {
|
private readonly resolveUriList: EntityResolver = urls => {
|
||||||
// only deal with the first url
|
// only deal with the first url
|
||||||
const url = urls
|
const url = urls
|
||||||
@ -87,16 +168,55 @@ export class DndService extends Service {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// todo: implement this
|
private readonly resolveBlocksuiteExternalData = (
|
||||||
private readonly resolveHTML: EntityResolver = _html => {
|
source: ExternalDragPayload
|
||||||
|
): Entity | null => {
|
||||||
|
const fakeDataTransfer = new Proxy(new DataTransfer(), {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === 'getData') {
|
||||||
|
return (type: string) => source.getStringData(type);
|
||||||
|
}
|
||||||
|
return target[prop as keyof DataTransfer];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshot = this.blocksuiteDndAPI.decodeSnapshot(fakeDataTransfer);
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.resolveBlockSnapshot(snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly resolveHTML: EntityResolver = html => {
|
||||||
try {
|
try {
|
||||||
// const parser = new DOMParser();
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
// const doc = parser.parseFromString(html, 'text/html');
|
// If drag from another secure context, the url-list
|
||||||
// return doc.body.innerText;
|
// will be "about:blank#blocked"
|
||||||
|
// We can still infer the url-list from the anchor tags
|
||||||
|
const urls = Array.from(doc.querySelectorAll('a'))
|
||||||
|
.map(a => a.href)
|
||||||
|
.join('\n');
|
||||||
|
return this.resolveUriList(urls);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore the error
|
// ignore the error
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly resolveBlockSnapshot = (
|
||||||
|
snapshot: SliceSnapshot
|
||||||
|
): Entity | null => {
|
||||||
|
for (const block of snapshot.content) {
|
||||||
|
if (
|
||||||
|
['affine:embed-linked-doc', 'affine:embed-synced-doc'].includes(
|
||||||
|
block.flavour
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
id: block.props.pageId as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -243,3 +243,83 @@ test('drag a page link in editor to favourites', async ({ page }) => {
|
|||||||
pageId
|
pageId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function enableNewDND(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// @ts-expect-error
|
||||||
|
window.currentEditor.doc.awarenessStore.setFlag('enable_new_dnd', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('drag a page card block to another page', async ({ page }) => {
|
||||||
|
await enableNewDND(page);
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await createLinkedPage(page, 'hi from another page');
|
||||||
|
|
||||||
|
const pageReference = page.locator('a').filter({
|
||||||
|
has: page.locator(
|
||||||
|
'.affine-reference-title:has-text("hi from another page")'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageLink = await pageReference.evaluate(
|
||||||
|
el => (el as HTMLAnchorElement).href
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageLink).toBeTruthy();
|
||||||
|
|
||||||
|
if (!pageLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageId = getDocIdFromUrl(pageLink);
|
||||||
|
|
||||||
|
await pageReference.hover();
|
||||||
|
|
||||||
|
const inlineToolbar = page.locator('reference-popup');
|
||||||
|
|
||||||
|
// convert page reference to card block
|
||||||
|
await inlineToolbar.getByRole('button', { name: 'Switch view' }).click();
|
||||||
|
await inlineToolbar.getByRole('button', { name: 'Card view' }).click();
|
||||||
|
|
||||||
|
// hover the card block to show the drag handle
|
||||||
|
const box = await page.locator('affine-embed-linked-doc-block').boundingBox();
|
||||||
|
|
||||||
|
expect(box).toBeTruthy();
|
||||||
|
|
||||||
|
if (!box) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(box.x - 5, box.y + box.height / 2);
|
||||||
|
|
||||||
|
await dragToFavourites(
|
||||||
|
page,
|
||||||
|
page.locator('.affine-drag-handle-container'),
|
||||||
|
pageId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drag a favourite page into blocksuite', async ({ page }) => {
|
||||||
|
await enableNewDND(page);
|
||||||
|
await clickNewPageButton(page, 'hi from page');
|
||||||
|
await page.getByTestId('pin-button').click();
|
||||||
|
const pageId = getCurrentDocIdFromUrl(page);
|
||||||
|
const item = page
|
||||||
|
.getByTestId(`explorer-favorites`)
|
||||||
|
.locator(`[data-testid="explorer-doc-${pageId}"]`);
|
||||||
|
await expect(item).toBeVisible();
|
||||||
|
|
||||||
|
// drag item into blocksuite editor
|
||||||
|
await dragTo(
|
||||||
|
page,
|
||||||
|
item,
|
||||||
|
page.locator('.affine-paragraph-block-container').first()
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.locator('affine-embed-linked-doc-block')).toContainText(
|
||||||
|
'hi from page'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -95,52 +95,88 @@ export const getPageByTitle = (page: Page, title: string) => {
|
|||||||
return page.getByTestId('page-list-item').getByText(title);
|
return page.getByTestId('page-list-item').getByText(title);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DragLocation =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top'
|
||||||
|
| 'bottom'
|
||||||
|
| 'center'
|
||||||
|
| 'left'
|
||||||
|
| 'right';
|
||||||
|
|
||||||
|
export const toPosition = (
|
||||||
|
rect: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
},
|
||||||
|
location: DragLocation
|
||||||
|
) => {
|
||||||
|
switch (location) {
|
||||||
|
case 'center':
|
||||||
|
return {
|
||||||
|
x: rect.width / 2,
|
||||||
|
y: rect.height / 2,
|
||||||
|
};
|
||||||
|
case 'top':
|
||||||
|
return { x: rect.width / 2, y: 1 };
|
||||||
|
case 'bottom':
|
||||||
|
return { x: rect.width / 2, y: rect.height - 1 };
|
||||||
|
|
||||||
|
case 'left':
|
||||||
|
return { x: 1, y: rect.height / 2 };
|
||||||
|
|
||||||
|
case 'right':
|
||||||
|
return { x: rect.width - 1, y: rect.height / 2 };
|
||||||
|
|
||||||
|
case 'top-left':
|
||||||
|
default:
|
||||||
|
return { x: 1, y: 1 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const dragTo = async (
|
export const dragTo = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
locator: Locator,
|
locator: Locator,
|
||||||
target: Locator,
|
target: Locator,
|
||||||
location:
|
location: DragLocation = 'center'
|
||||||
| 'top-left'
|
|
||||||
| 'top'
|
|
||||||
| 'bottom'
|
|
||||||
| 'center'
|
|
||||||
| 'left'
|
|
||||||
| 'right' = 'center'
|
|
||||||
) => {
|
) => {
|
||||||
await locator.hover();
|
await locator.hover();
|
||||||
|
const locatorElement = await locator.boundingBox();
|
||||||
|
if (!locatorElement) {
|
||||||
|
throw new Error('locator element not found');
|
||||||
|
}
|
||||||
|
const locatorCenter = toPosition(locatorElement, 'center');
|
||||||
|
await page.mouse.move(
|
||||||
|
locatorElement.x + locatorCenter.x,
|
||||||
|
locatorElement.y + locatorCenter.y
|
||||||
|
);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(1, 1);
|
await page.waitForTimeout(100);
|
||||||
|
await page.mouse.move(
|
||||||
|
locatorElement.x + locatorCenter.x + 1,
|
||||||
|
locatorElement.y + locatorCenter.y + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.mouse.move(1, 1, {
|
||||||
|
steps: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
await target.hover();
|
||||||
|
|
||||||
const targetElement = await target.boundingBox();
|
const targetElement = await target.boundingBox();
|
||||||
if (!targetElement) {
|
if (!targetElement) {
|
||||||
throw new Error('target element not found');
|
throw new Error('target element not found');
|
||||||
}
|
}
|
||||||
const position = (() => {
|
const targetPosition = toPosition(targetElement, location);
|
||||||
switch (location) {
|
await page.mouse.move(
|
||||||
case 'center':
|
targetElement.x + targetPosition.x,
|
||||||
return {
|
targetElement.y + targetPosition.y,
|
||||||
x: targetElement.width / 2,
|
{
|
||||||
y: targetElement.height / 2,
|
steps: 10,
|
||||||
};
|
|
||||||
case 'top':
|
|
||||||
return { x: targetElement.width / 2, y: 1 };
|
|
||||||
case 'bottom':
|
|
||||||
return { x: targetElement.width / 2, y: targetElement.height - 1 };
|
|
||||||
|
|
||||||
case 'left':
|
|
||||||
return { x: 1, y: targetElement.height / 2 };
|
|
||||||
|
|
||||||
case 'right':
|
|
||||||
return { x: targetElement.width - 1, y: targetElement.height / 2 };
|
|
||||||
|
|
||||||
case 'top-left':
|
|
||||||
default:
|
|
||||||
return { x: 1, y: 1 };
|
|
||||||
}
|
}
|
||||||
})();
|
);
|
||||||
await target.hover({
|
await page.waitForTimeout(100);
|
||||||
position: position,
|
|
||||||
});
|
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user