From dc7d1282529e0acb0141438247947bc59d2e470b Mon Sep 17 00:00:00 2001 From: pengx17 Date: Wed, 11 Dec 2024 08:12:01 +0000 Subject: [PATCH] feat(core): allow bs snapshot dragging targets (#9093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix AF-1924, AF-1848, AF-1928, AF-1931 dnd between affine & editor
🎥 Video uploaded on Graphite:
--- .../src/main/windows-manager/tab-views.ts | 2 + .../frontend/component/src/ui/dnd/context.ts | 14 +- .../component/src/ui/dnd/dnd.stories.tsx | 2 +- .../component/src/ui/dnd/draggable.ts | 60 +++--- .../component/src/ui/dnd/drop-target.ts | 55 +++--- .../frontend/component/src/ui/dnd/types.ts | 40 +++- .../detail-page/detail-page-header.css.ts | 5 + .../detail-page/detail-page-header.tsx | 21 ++- .../src/desktop/pages/workspace/index.tsx | 5 +- .../app-tabs-header/views/app-tabs-header.tsx | 38 +++- .../core/src/modules/dnd/services/index.ts | 174 +++++++++++++++--- tests/affine-local/e2e/drag-page.spec.ts | 80 ++++++++ tests/kit/utils/page-logic.ts | 102 ++++++---- 13 files changed, 457 insertions(+), 141 deletions(-) diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts index 30eb9d44ef..b0c8189386 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts @@ -171,6 +171,7 @@ export class WebContentViewsManager { ready: ready.has(w.id), activeViewIndex: w.activeViewIndex, views: w.views, + basename: w.basename, }; }); }), @@ -952,6 +953,7 @@ export const onTabsStatusChange = ( pinned: boolean; activeViewIndex: number; views: WorkbenchViewMeta[]; + basename: string; }[] ) => void ) => { diff --git a/packages/frontend/component/src/ui/dnd/context.ts b/packages/frontend/component/src/ui/dnd/context.ts index 0e19803f2a..807aac44a8 100644 --- a/packages/frontend/component/src/ui/dnd/context.ts +++ b/packages/frontend/component/src/ui/dnd/context.ts @@ -1,13 +1,23 @@ import { createContext } from 'react'; -import type { DNDData, ExternalDataAdapter } from './types'; +import type { DNDData, fromExternalData, toExternalData } from './types'; export const DNDContext = createContext<{ /** * 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. * * @default undefined */ - externalDataAdapter?: ExternalDataAdapter; + fromExternalData?: fromExternalData; + + /** + * Convert the draggable data to the external data. + * Mainly used to be consumed by blocksuite. + * + * @default undefined + */ + toExternalData?: toExternalData; }>({}); diff --git a/packages/frontend/component/src/ui/dnd/dnd.stories.tsx b/packages/frontend/component/src/ui/dnd/dnd.stories.tsx index 85bb51d2a3..085af90290 100644 --- a/packages/frontend/component/src/ui/dnd/dnd.stories.tsx +++ b/packages/frontend/component/src/ui/dnd/dnd.stories.tsx @@ -92,7 +92,7 @@ export const DropTarget: StoryFn<{ canDrop: boolean }> = ({ canDrop }) => { onDrop(data) { setDropData(prev => prev + data.source.data.text); }, - externalDataAdapter(args) { + fromExternalData(args) { return { text: args.source.getStringData(args.source.types[0]) || 'no value', }; diff --git a/packages/frontend/component/src/ui/dnd/draggable.ts b/packages/frontend/component/src/ui/dnd/draggable.ts index 8dc8a3a490..21a1c703e8 100644 --- a/packages/frontend/component/src/ui/dnd/draggable.ts +++ b/packages/frontend/component/src/ui/dnd/draggable.ts @@ -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 { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; 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 type { DNDData } from './types'; - -type DraggableGetFeedback = Parameters< - NonNullable[0]['getInitialData']> ->[0]; - -type DraggableGet = T | ((data: DraggableGetFeedback) => T); - -function draggableGet( - get: T -): T extends undefined - ? undefined - : T extends DraggableGet - ? (args: DraggableGetFeedback) => I - : never { - if (get === undefined) { - return undefined as any; - } - return ((args: DraggableGetFeedback) => - typeof get === 'function' ? (get as any)(args) : get) as any; -} +import { DNDContext } from './context'; +import { + type DNDData, + type DraggableGet, + draggableGet, + type DraggableGetFeedback, + type toExternalData, +} from './types'; export interface DraggableOptions { data?: DraggableGet; - dataForExternal?: DraggableGet<{ - [Key in - | 'text/uri-list' - | 'text/plain' - | 'text/html' - | 'Files' - // eslint-disable-next-line @typescript-eslint/ban-types - | (string & {})]?: string; - }>; + toExternalData?: toExternalData; canDrag?: DraggableGet; disableDragPreview?: boolean; dragPreviewPosition?: DraggableDragPreviewPosition; @@ -82,8 +61,23 @@ export const useDraggable = ( const enableDropTarget = useRef(false); const enableDragging = useRef(false); + const context = useContext(DNDContext); + // 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(() => { if (!dragRef.current) { @@ -110,7 +104,7 @@ export const useDraggable = ( dragHandle: dragHandleRef.current ?? undefined, canDrag: draggableGet(options.canDrag), getInitialData: draggableGet(options.data), - getInitialDataForExternal: draggableGet(options.dataForExternal), + getInitialDataForExternal: draggableGet(options.toExternalData), onDragStart: args => { if (enableDragging.current) { setDragging(true); diff --git a/packages/frontend/component/src/ui/dnd/drop-target.ts b/packages/frontend/component/src/ui/dnd/drop-target.ts index d00d54c578..daf0efd6c9 100644 --- a/packages/frontend/component/src/ui/dnd/drop-target.ts +++ b/packages/frontend/component/src/ui/dnd/drop-target.ts @@ -20,7 +20,7 @@ import { import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { DNDContext } from './context'; -import type { DNDData, ExternalDataAdapter } from './types'; +import type { DNDData, fromExternalData } from './types'; export type DropTargetDropEvent = { treeInstruction: Instruction | null; @@ -74,8 +74,8 @@ const getAdaptedEventArgs = < isDropEvent = false ): Args => { const data = - isExternalDrag(args) && options.externalDataAdapter - ? options.externalDataAdapter( + isExternalDrag(args) && options.fromExternalData + ? options.fromExternalData( // @ts-expect-error hack for external data adapter (source has no data field) args as ExternalGetDataFeedbackArgs, isDropEvent @@ -172,10 +172,10 @@ export interface DropTargetOptions { * external data adapter. * Will use the external data adapter from the context if not provided. */ - externalDataAdapter?: ExternalDataAdapter; + fromExternalData?: fromExternalData; /** * 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 */ @@ -217,17 +217,17 @@ export const useDropTarget = ( const options = useMemo(() => { const opts = getOptions(); - const allowExternal = opts.allowExternal ?? !!opts.externalDataAdapter; + const allowExternal = opts.allowExternal ?? !!opts.fromExternalData; return { ...opts, allowExternal, - externalDataAdapter: allowExternal - ? (opts.externalDataAdapter ?? - (dropTargetContext.externalDataAdapter as ExternalDataAdapter)) + fromExternalData: allowExternal + ? (opts.fromExternalData ?? + (dropTargetContext.fromExternalData as fromExternalData)) : undefined, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [...deps, dropTargetContext.externalDataAdapter]); + }, [...deps, dropTargetContext.fromExternalData]); const dropTargetOptions = useMemo(() => { const wrappedCanDrop = dropTargetGet(options.canDrop, options); @@ -240,7 +240,7 @@ export const useDropTarget = ( // 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) { + if (isExternalDrag(args) && options.fromExternalData) { return true; } return wrappedCanDrop(args); @@ -249,20 +249,6 @@ export const useDropTarget = ( getDropEffect: dropTargetGet(options.dropEffect, options), getIsSticky: dropTargetGet(options.isSticky, options), onDrop: (_args: DropTargetDropEvent) => { - // 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) { setDraggedOver(false); } @@ -292,6 +278,21 @@ export const useDropTarget = ( if (dropTargetRef.current) { 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 ( args.location.current.dropTargets[0]?.element === dropTargetRef.current @@ -451,11 +452,11 @@ export const useDropTarget = ( }, [dropTargetOptions]); useEffect(() => { - if (!dropTargetRef.current || !options.externalDataAdapter) { + if (!dropTargetRef.current || !options.fromExternalData) { return; } return dropTargetForExternal(dropTargetOptions as any); - }, [dropTargetOptions, options.externalDataAdapter]); + }, [dropTargetOptions, options.fromExternalData]); return { dropTargetRef, diff --git a/packages/frontend/component/src/ui/dnd/types.ts b/packages/frontend/component/src/ui/dnd/types.ts index 01555cf0f5..0bf35657bc 100644 --- a/packages/frontend/component/src/ui/dnd/types.ts +++ b/packages/frontend/component/src/ui/dnd/types.ts @@ -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'; export interface DNDData< @@ -12,7 +13,44 @@ export type ExternalGetDataFeedbackArgs = Parameters< NonNullable[0]['getData']> >[0]; -export type ExternalDataAdapter = ( +export type fromExternalData = ( args: ExternalGetDataFeedbackArgs, isDropEvent?: boolean ) => D['draggable']; + +export type DraggableGetFeedback = Parameters< + NonNullable[0]['getInitialData']> +>[0]; + +type DraggableGetFeedbackArgs = Parameters< + NonNullable[0]['getInitialData']> +>[0]; + +export function draggableGet( + get: T +): T extends undefined + ? undefined + : T extends DraggableGet + ? (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 | ((data: DraggableGetFeedback) => T); + +export type toExternalData = ( + args: DraggableGetFeedbackArgs, + data?: DraggableGet +) => { + [Key in + | 'text/uri-list' + | 'text/plain' + | 'text/html' + | 'Files' + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {})]?: string; +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.css.ts b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.css.ts index 4ecc6c5ea9..8e5018f4b0 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx index e92c9675d3..1fb14a4a32 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx @@ -174,7 +174,7 @@ export function DetailPageHeader( docId: page.id, }); - const { dragRef, dragHandleRef, dragging } = + const { dragRef, dragging, CustomDragPreview } = useDraggable(() => { return { data: { @@ -187,7 +187,7 @@ export function DetailPageHeader( id: page.id, }, }, - disableDragPreview: true, + dragPreviewPosition: 'pointer-outside', }; }, [page.id]); @@ -203,13 +203,14 @@ export function DetailPageHeader( }, [dragging, onDragging]); return ( -
- - {inner} -
+ <> +
+ + {inner} +
+ +
{inner}
+
+ ); } diff --git a/packages/frontend/core/src/desktop/pages/workspace/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/index.tsx index 04ca2a1d55..4d3d46c222 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/index.tsx @@ -135,9 +135,10 @@ const DNDContextProvider = ({ children }: PropsWithChildren) => { const dndService = useService(DndService); const contextValue = useMemo(() => { return { - externalDataAdapter: dndService.externalDataAdapter, + fromExternalData: dndService.fromExternalData, + toExternalData: dndService.toExternalData, }; - }, [dndService.externalDataAdapter]); + }, [dndService.fromExternalData, dndService.toExternalData]); return ( {children} ); diff --git a/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx b/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx index 9d0d088623..1ea0541fcb 100644 --- a/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx +++ b/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx @@ -31,6 +31,7 @@ import { import { AppSidebarService } from '../../app-sidebar'; import { DesktopApiService } from '../../desktop-api'; +import { resolveLinkToDoc } from '../../navigation'; import { iconNameToIcon } from '../../workbench/constants'; import { DesktopStateSynchronizer } from '../../workbench/services/desktop-state-synchronizer'; import { @@ -176,23 +177,50 @@ const WorkbenchTab = ({ dropEffect: 'move', canDrop: tabCanDrop(workbench), isSticky: true, + allowExternal: true, }), [onDrop, workbench] ); - const { dragRef } = useDraggable( - () => ({ + const { dragRef } = useDraggable(() => { + 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, data: { from: { at: 'app-header:tabs', tabId: workbench.id, }, + entity, }, dragPreviewPosition: 'pointer-outside', - }), - [dnd, workbench.id] - ); + toExternalData: () => { + return { + 'text/uri-list': urls.join('\n'), + }; + }, + }; + }, [dnd, workbench.basename, workbench.id, workbench.views]); return (
AffineDNDData['draggable']['entity'] | null; +type Entity = AffineDNDData['draggable']['entity']; +type EntityResolver = (data: string) => Entity | null; + +type ExternalDragPayload = ExternalGetDataFeedbackArgs['source']; export class DndService extends Service { constructor( @@ -20,13 +30,47 @@ export class DndService extends Service { super(); // order matters - this.resolvers.set('text/html', this.resolveHTML); - this.resolvers.set('text/uri-list', this.resolveUriList); + this.resolvers.push(this.resolveBlocksuiteExternalData); + + 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(); + private readonly resolvers: (( + source: ExternalDragPayload + ) => Entity | null)[] = []; - externalDataAdapter: ExternalDataAdapter = ( + 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 = ( args: ExternalGetDataFeedbackArgs, isDropEvent?: boolean ) => { @@ -36,19 +80,15 @@ export class DndService extends Service { const from: AffineDNDData['draggable']['from'] = { 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 - for (const [type, resolver] of this.resolvers) { - if (args.source.types.includes(type)) { - const stringData = args.source.getStringData(type); - if (stringData) { - const candidate = resolver(stringData); - if (candidate) { - entity = candidate; - break; - } - } + for (const resolver of this.resolvers) { + const candidate = resolver(args.source); + if (candidate) { + entity = candidate; + break; } } @@ -62,6 +102,47 @@ export class DndService extends Service { }; }; + toExternalData: toExternalData = (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 = `
`; + + return { + 'text/html': html, + }; + }; + private readonly resolveUriList: EntityResolver = urls => { // only deal with the first url const url = urls @@ -87,16 +168,55 @@ export class DndService extends Service { return null; }; - // todo: implement this - private readonly resolveHTML: EntityResolver = _html => { + private readonly resolveBlocksuiteExternalData = ( + 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 { - // const parser = new DOMParser(); - // const doc = parser.parseFromString(html, 'text/html'); - // return doc.body.innerText; + const doc = new DOMParser().parseFromString(html, 'text/html'); + // If drag from another secure context, the url-list + // 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 { // ignore the error 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; }; } diff --git a/tests/affine-local/e2e/drag-page.spec.ts b/tests/affine-local/e2e/drag-page.spec.ts index 9f4866015d..133515c353 100644 --- a/tests/affine-local/e2e/drag-page.spec.ts +++ b/tests/affine-local/e2e/drag-page.spec.ts @@ -243,3 +243,83 @@ test('drag a page link in editor to favourites', async ({ page }) => { 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' + ); +}); diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index da0d3857bc..aebf04523a 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -95,52 +95,88 @@ export const getPageByTitle = (page: Page, title: string) => { 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 ( page: Page, locator: Locator, target: Locator, - location: - | 'top-left' - | 'top' - | 'bottom' - | 'center' - | 'left' - | 'right' = 'center' + location: DragLocation = 'center' ) => { 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.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(); if (!targetElement) { throw new Error('target element not found'); } - const position = (() => { - switch (location) { - case 'center': - return { - x: targetElement.width / 2, - y: targetElement.height / 2, - }; - 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 }; + const targetPosition = toPosition(targetElement, location); + await page.mouse.move( + targetElement.x + targetPosition.x, + targetElement.y + targetPosition.y, + { + steps: 10, } - })(); - await target.hover({ - position: position, - }); + ); + await page.waitForTimeout(100); await page.mouse.up(); };