import type { ResolvedPos, Slice } from 'prosemirror-model' import { NodeSelection, Plugin, PluginKey, TextSelection, type PluginView, } from 'prosemirror-state' import type { EditorView } from 'prosemirror-view' import { buildGetTarget, type GetTarget } from './drop-target' import type { DragEventHandler, ShowHandler } from './types' /** * @public */ export interface DropIndicatorPluginOptions { /** * A callback that is called when the drop indicator should be shown. */ onShow?: ShowHandler /** * A callback that is called when the drop indicator should be hidden. */ onHide?: VoidFunction /** * A callback that is called when the `dragover` event is fired. You can * return `false` to disable the current drop point and thus hide the drop * indicator. */ onDrag?: DragEventHandler } /** * @public * * @param options - The options for the drop indicator plugin. */ export function createDropIndicatorPlugin( options: DropIndicatorPluginOptions, ): Plugin { let getTarget: GetTarget | undefined return new Plugin({ key: new PluginKey('prosemirror-drop-indicator'), view: (view) => { getTarget = buildGetTarget(view, options.onDrag) return createDropIndicatorView(view, getTarget, options) }, props: { handleDrop(view, event, slice, move): boolean { if (!getTarget) return false const target = getTarget([event.clientX, event.clientY], event) if (!target) return false event.preventDefault() const insertPos = target[0] const tr = view.state.tr if (move) { const { node } = (view.dragging as { slice: Slice move: boolean node?: NodeSelection }) || {} if (node) node.replace(tr) else tr.deleteSelection() } const pos = tr.mapping.map(insertPos) const isNode = slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 const beforeInsert = tr.doc if (isNode) tr.replaceRangeWith(pos, pos, slice.content.firstChild!) else tr.replaceRange(pos, pos, slice) if (tr.doc.eq(beforeInsert)) { return true } const $pos = tr.doc.resolve(pos) if ( isNode && NodeSelection.isSelectable(slice.content.firstChild!) && $pos.nodeAfter && $pos.nodeAfter.sameMarkup(slice.content.firstChild!) ) { tr.setSelection(new NodeSelection($pos)) } else { let end = tr.mapping.map(insertPos) tr.mapping.maps[tr.mapping.maps.length - 1].forEach( (_from, _to, _newFrom, newTo) => (end = newTo), ) tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end))) } view.focus() view.dispatch(tr.setMeta('uiEvent', 'drop')) return true }, }, }) } function selectionBetween( view: EditorView, $anchor: ResolvedPos, $head: ResolvedPos, bias?: number, ) { return ( view.someProp('createSelectionBetween', (f) => f(view, $anchor, $head)) || TextSelection.between($anchor, $head, bias) ) } function createDropIndicatorView( view: EditorView, getTarget: GetTarget, options: DropIndicatorPluginOptions, ): PluginView { const dom = view.dom let hideId: ReturnType | undefined let prevX: number | undefined let prevY: number | undefined let hasDragOverEvent: boolean = false const scheduleHide = () => { if (hideId) { clearTimeout(hideId) } hasDragOverEvent = false hideId = setTimeout(() => { if (hasDragOverEvent) return options.onHide?.() }, 30) } const handleDragOver = (event: DragEvent): void => { hasDragOverEvent = true const { clientX, clientY } = event if (prevX === clientX && prevY === clientY) { return } prevX = clientX prevY = clientY const target = getTarget([clientX, clientY], event) if (!target) { scheduleHide() return } else { const [pos, [x1, y1, x2, y2]] = target const line = { p1: { x: x1, y: y1 }, p2: { x: x2, y: y2 } } options.onShow?.({ view, pos, line }) } } dom.addEventListener('dragover', handleDragOver) dom.addEventListener('dragend', scheduleHide) dom.addEventListener('drop', scheduleHide) dom.addEventListener('dragleave', scheduleHide) const destroy = () => { dom.removeEventListener('dragover', handleDragOver) dom.removeEventListener('dragend', scheduleHide) dom.removeEventListener('drop', scheduleHide) dom.removeEventListener('dragleave', scheduleHide) } return { destroy } }