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:
pengx17 2024-12-11 08:12:01 +00:00
parent 331e674e8b
commit dc7d128252
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
13 changed files with 457 additions and 141 deletions

View File

@ -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
) => { ) => {

View File

@ -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>;
}>({}); }>({});

View File

@ -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',
}; };

View File

@ -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);

View File

@ -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,

View File

@ -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;
};

View File

@ -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',
});

View File

@ -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>
</>
); );
} }

View File

@ -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>
); );

View File

@ -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

View File

@ -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;
}; };
} }

View File

@ -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'
);
});

View File

@ -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();
}; };