import { isHTMLElement } from '@ocavue/utils'
import type { Node as ProseMirrorNode } from 'prosemirror-model'
import { NodeSelection } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view'
import type { DragEventHandler } from './types'
type Point = readonly [x: number, y: number]
type Line = readonly [x1: number, y1: number, x2: number, y2: number]
/**
* @internal
*/
type DropTarget = readonly [pos: number, line: Line]
function getTargetsByView(view: EditorView): DropTarget[] {
type StackItem = [pos: number, node: ProseMirrorNode]
const stack: StackItem[] = [[-1, view.state.doc]]
const targets: DropTarget[] = []
while (stack.length > 0) {
const [pos, node] = stack.pop()!
if (pos >= 0) {
const dom = view.nodeDOM(pos)
if (dom && isHTMLElement(dom)) {
const rect = dom.getBoundingClientRect()
const { top, bottom, left: x1, right: x2 } = rect
targets.push(
[pos, [x1, top, x2, top]],
[pos + node.nodeSize, [x1, bottom, x2, bottom]],
)
}
}
if (node.isBlock && !node.isTextblock) {
let childPos = pos + 1
for (const child of node.children) {
stack.push([childPos, child])
childPos += child.nodeSize
}
}
}
return targets
}
/**
* @internal
*/
export type GetTarget = (
point: Point,
event: DragEvent,
) => DropTarget | undefined
/**
* @internal
*/
export function buildGetTarget(
view: EditorView,
onDrag?: DragEventHandler,
): GetTarget {
let prevTargets: DropTarget[] = []
let prevDoc: ProseMirrorNode | undefined
let prevRect: DOMRect | undefined
const getTargets = (): DropTarget[] => {
const rect = view.dom.getBoundingClientRect()
const doc = view.state.doc
if (
prevTargets &&
prevDoc &&
prevRect &&
rect.width === prevRect.width &&
rect.height === prevRect.height &&
rect.x === prevRect.x &&
rect.y === prevRect.y &&
prevDoc.eq(doc)
) {
return prevTargets
}
prevRect = rect
prevDoc = doc
prevTargets = getTargetsByView(view)
return prevTargets
}
const getTargetImpl: GetTarget = (point, event) => {
if (!view.editable || view.isDestroyed) {
return
}
const compare = (p1: DropTarget, p2: DropTarget): number => {
const [pos1, line1] = p1
const [pos2, line2] = p2
const p1Distance = pointLineDistance(point, line1)
const p2Distance = pointLineDistance(point, line2)
return p1Distance - p2Distance || pos1 - pos2
}
let targets = getTargets()
targets.sort(compare)
// Only consider the first few targets for performance reasons.
targets = targets.slice(0, 8)
// Find the closest valid target.
const target = targets.find(
(target) => onDrag?.({ view, pos: target[0], event }) !== false,
)
// If the dragging node is already at the target position, we ignore this
// target. Notice that we don't pick the second better target here.
if (target && isDraggingToItself(view, target[0])) {
return undefined
}
return target
}
let prevPoint: Point | undefined
let prevTarget: DropTarget | undefined
const getTargetCached: GetTarget = (point, event) => {
if (prevPoint && pointEqual(prevPoint, point)) {
return prevTarget
}
prevPoint = point
prevTarget = getTargetImpl(point, event)
return prevTarget
}
return getTargetCached
}
function pointEqual(a: Point, b: Point) {
return a[0] === b[0] && a[1] === b[1]
}
function pointPointDistance(a: Point, b: Point) {
// Manhattan distance
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1])
}
function pointLineDistance(point: Point, line: Line) {
// Notice that we are actually not calculating the distance between the point
// and the line. Instead, we are calculating the distance between the point
// and the two endpoints of the line.
return Math.min(
pointPointDistance(point, [line[0], line[1]]),
pointPointDistance(point, [line[2], line[3]]),
)
}
/**
* Whether the dragging node is being dragged to the same position. For example,
* dragging a list node into a new position that is just below the list node, or
* dragging a nested quoteblock into itself.
*/
function isDraggingToItself(view: EditorView, pos: number) {
const dragging = view.dragging
if (!dragging) return
const { move } = dragging
if (!move) return
const selection = view.state.selection
if (!(selection instanceof NodeSelection)) return
const { from, to } = selection
return from <= pos && pos <= to
}