mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 22:07:09 +08: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:
@@ -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,
|
||||
});
|
||||
|
||||
const { dragRef, dragHandleRef, dragging } =
|
||||
const { dragRef, dragging, CustomDragPreview } =
|
||||
useDraggable<AffineDNDData>(() => {
|
||||
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 (
|
||||
<div className={styles.root} ref={dragRef} data-dragging={dragging}>
|
||||
<DragHandle
|
||||
ref={dragHandleRef}
|
||||
dragging={dragging}
|
||||
className={styles.dragHandle}
|
||||
/>
|
||||
{inner}
|
||||
</div>
|
||||
<>
|
||||
<div className={styles.root} ref={dragRef} data-dragging={dragging}>
|
||||
<DragHandle dragging={dragging} className={styles.dragHandle} />
|
||||
{inner}
|
||||
</div>
|
||||
<CustomDragPreview>
|
||||
<div className={styles.dragPreview}>{inner}</div>
|
||||
</CustomDragPreview>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DNDContext.Provider value={contextValue}>{children}</DNDContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<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,
|
||||
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 (
|
||||
<div
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import type {
|
||||
ExternalDataAdapter,
|
||||
ExternalGetDataFeedbackArgs,
|
||||
import {
|
||||
type ExternalGetDataFeedbackArgs,
|
||||
type fromExternalData,
|
||||
type toExternalData,
|
||||
} from '@affine/component';
|
||||
import { createPageModeSpecs } from '@affine/core/components/blocksuite/block-suite-editor/specs/page';
|
||||
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 { Service } from '@toeverything/infra';
|
||||
import { getAFFiNEWorkspaceSchema, Service } from '@toeverything/infra';
|
||||
|
||||
import { resolveLinkToDoc } from '../../navigation';
|
||||
|
||||
type EntityResolver = (
|
||||
data: string
|
||||
) => 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<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,
|
||||
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<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 => {
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user