import type { BlockService } from '@blockeditor/block-std'; import { assertExists, throttle } from '@blockeditor/global/utils'; import type { EditorHost } from '@blockeditor/lit'; import type { BlockModel } from '@blockeditor/store'; import { calcDropTarget, type DropResult, getClosestBlockElementByPoint, getModelByBlockComponent, isInsideDocEditor, matchFlavours, Point, } from '../../_common/utils/index.js'; import type { DragIndicator } from './drag-indicator.js'; export type onDropProps = { files: File[]; targetModel: BlockModel | null; place: 'before' | 'after'; point: Point; }; export type FileDropOptions = { flavour: string; onDrop?: ({ files, targetModel, place, point, }: onDropProps) => Promise | void; }; export class FileDropManager { private _blockService: BlockService; private _fileDropOptions: FileDropOptions; private static _dropResult: DropResult | null = null; private _indicator!: DragIndicator; constructor(blockService: BlockService, fileDropOptions: FileDropOptions) { this._blockService = blockService; this._fileDropOptions = fileDropOptions; this._indicator = ( document.querySelector('affine-drag-indicator') ); if (!this._indicator) { this._indicator = ( document.createElement('affine-drag-indicator') ); document.body.appendChild(this._indicator); } if (fileDropOptions.onDrop) { this._blockService.disposables.addFromEvent( this._blockService.std.host, 'drop', this._onDrop ); } } get editorHost(): EditorHost { return this._blockService.std.host as EditorHost; } get type(): 'before' | 'after' { return !FileDropManager._dropResult || FileDropManager._dropResult.type !== 'before' ? 'after' : 'before'; } get targetModel(): BlockModel | null { const pageBlock = this._blockService.page.root; assertExists(pageBlock); let targetModel = FileDropManager._dropResult?.modelState.model || null; if (!targetModel && isInsideDocEditor(this.editorHost)) { const lastNote = pageBlock.children[pageBlock.children.length - 1]; if (!matchFlavours(lastNote, ['affine:note'])) { throw new Error('The last block is not a note block.'); } targetModel = lastNote.lastItem(); } return targetModel; } private _onDragOver = (event: DragEvent) => { event.preventDefault(); // allow only external drag-and-drop files const effectAllowed = event.dataTransfer?.effectAllowed ?? 'none'; if (effectAllowed !== 'all') return; const { clientX, clientY } = event; const point = new Point(clientX, clientY); const element = getClosestBlockElementByPoint(point.clone()); let result: DropResult | null = null; if (element) { const model = getModelByBlockComponent(element); result = calcDropTarget(point, model, element); } if (result) { FileDropManager._dropResult = result; this._indicator.rect = result.rect; } else { FileDropManager._dropResult = null; this._indicator.rect = null; } }; onDragOver = throttle(this._onDragOver, 1000 / 60); private _onDrop = (event: DragEvent) => { const { onDrop } = this._fileDropOptions; if (!onDrop) return; event.preventDefault(); // allow only external drag-and-drop files const effectAllowed = event.dataTransfer?.effectAllowed ?? 'none'; if (effectAllowed !== 'all') return; const droppedFiles = event.dataTransfer?.files; if (!droppedFiles || !droppedFiles.length) return; const targetModel = this.targetModel; const place = this.type; const { clientX, clientY } = event; const point = new Point(clientX, clientY); onDrop({ files: [...droppedFiles], targetModel, place, point })?.catch( console.error ); this._indicator.rect = null; }; }