/**
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module clipboard/dragdroptarget
 */
import { Plugin } from '@ckeditor/ckeditor5-core';
import { global, Rect, DomEmitterMixin, delay, ResizeObserver } from '@ckeditor/ckeditor5-utils';
import LineView from './lineview.js';
import { throttle } from 'lodash-es';
/**
 * Part of the Drag and Drop handling. Responsible for finding and displaying the drop target.
 *
 * @internal
 */
export default class DragDropTarget extends Plugin {
    constructor() {
        super(...arguments);
        /**
         * A delayed callback removing the drop marker.
         *
         * @internal
         */
        this.removeDropMarkerDelayed = delay(() => this.removeDropMarker(), 40);
        /**
         * A throttled callback updating the drop marker.
         */
        this._updateDropMarkerThrottled = throttle(targetRange => this._updateDropMarker(targetRange), 40);
        /**
         * A throttled callback reconverting the drop parker.
         */
        this._reconvertMarkerThrottled = throttle(() => {
            if (this.editor.model.markers.has('drop-target')) {
                this.editor.editing.reconvertMarker('drop-target');
            }
        }, 0);
        /**
         * The horizontal drop target line view.
         */
        this._dropTargetLineView = new LineView();
        /**
         * DOM Emitter.
         */
        this._domEmitter = new (DomEmitterMixin())();
        /**
         * Map of document scrollable elements.
         */
        this._scrollables = new Map();
    }
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'DragDropTarget';
    }
    /**
     * @inheritDoc
     */
    init() {
        this._setupDropMarker();
    }
    /**
     * @inheritDoc
     */
    destroy() {
        this._domEmitter.stopListening();
        for (const { resizeObserver } of this._scrollables.values()) {
            resizeObserver.destroy();
        }
        this._updateDropMarkerThrottled.cancel();
        this.removeDropMarkerDelayed.cancel();
        this._reconvertMarkerThrottled.cancel();
        return super.destroy();
    }
    /**
     * Finds the drop target range and updates the drop marker.
     *
     * @internal
     */
    updateDropMarker(targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange) {
        this.removeDropMarkerDelayed.cancel();
        const targetRange = findDropTargetRange(this.editor, targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange);
        /* istanbul ignore next -- @preserve */
        if (!targetRange) {
            return;
        }
        if (draggedRange && draggedRange.containsRange(targetRange)) {
            // Target range is inside the dragged range.
            return this.removeDropMarker();
        }
        this._updateDropMarkerThrottled(targetRange);
    }
    /**
     * Finds the final drop target range.
     *
     * @internal
     */
    getFinalDropRange(targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange) {
        const targetRange = findDropTargetRange(this.editor, targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange);
        // The dragging markers must be removed after searching for the target range because sometimes
        // the target lands on the marker itself.
        this.removeDropMarker();
        return targetRange;
    }
    /**
     * Removes the drop target marker.
     *
     * @internal
     */
    removeDropMarker() {
        const model = this.editor.model;
        this.removeDropMarkerDelayed.cancel();
        this._updateDropMarkerThrottled.cancel();
        this._dropTargetLineView.isVisible = false;
        if (model.markers.has('drop-target')) {
            model.change(writer => {
                writer.removeMarker('drop-target');
            });
        }
    }
    /**
     * Creates downcast conversion for the drop target marker.
     */
    _setupDropMarker() {
        const editor = this.editor;
        editor.ui.view.body.add(this._dropTargetLineView);
        // Drop marker conversion for hovering over widgets.
        editor.conversion.for('editingDowncast').markerToHighlight({
            model: 'drop-target',
            view: {
                classes: ['ck-clipboard-drop-target-range']
            }
        });
        // Drop marker conversion for in text and block drop target.
        editor.conversion.for('editingDowncast').markerToElement({
            model: 'drop-target',
            view: (data, { writer }) => {
                // Inline drop.
                if (editor.model.schema.checkChild(data.markerRange.start, '$text')) {
                    this._dropTargetLineView.isVisible = false;
                    return this._createDropTargetPosition(writer);
                }
                // Block drop.
                else {
                    if (data.markerRange.isCollapsed) {
                        this._updateDropTargetLine(data.markerRange);
                    }
                    else {
                        this._dropTargetLineView.isVisible = false;
                    }
                }
            }
        });
    }
    /**
     * Updates the drop target marker to the provided range.
     *
     * @param targetRange The range to set the marker to.
     */
    _updateDropMarker(targetRange) {
        const editor = this.editor;
        const markers = editor.model.markers;
        editor.model.change(writer => {
            if (markers.has('drop-target')) {
                if (!markers.get('drop-target').getRange().isEqual(targetRange)) {
                    writer.updateMarker('drop-target', { range: targetRange });
                }
            }
            else {
                writer.addMarker('drop-target', {
                    range: targetRange,
                    usingOperation: false,
                    affectsData: false
                });
            }
        });
    }
    /**
     * Creates the UI element for vertical (in-line) drop target.
     */
    _createDropTargetPosition(writer) {
        return writer.createUIElement('span', { class: 'ck ck-clipboard-drop-target-position' }, function (domDocument) {
            const domElement = this.toDomElement(domDocument);
            // Using word joiner to make this marker as high as text and also making text not break on marker.
            domElement.append('\u2060', domDocument.createElement('span'), '\u2060');
            return domElement;
        });
    }
    /**
     * Updates the horizontal drop target line.
     */
    _updateDropTargetLine(range) {
        const editing = this.editor.editing;
        const nodeBefore = range.start.nodeBefore;
        const nodeAfter = range.start.nodeAfter;
        const nodeParent = range.start.parent;
        const viewElementBefore = nodeBefore ? editing.mapper.toViewElement(nodeBefore) : null;
        const domElementBefore = viewElementBefore ? editing.view.domConverter.mapViewToDom(viewElementBefore) : null;
        const viewElementAfter = nodeAfter ? editing.mapper.toViewElement(nodeAfter) : null;
        const domElementAfter = viewElementAfter ? editing.view.domConverter.mapViewToDom(viewElementAfter) : null;
        const viewElementParent = editing.mapper.toViewElement(nodeParent);
        if (!viewElementParent) {
            return;
        }
        const domElementParent = editing.view.domConverter.mapViewToDom(viewElementParent);
        const domScrollableRect = this._getScrollableRect(viewElementParent);
        const { scrollX, scrollY } = global.window;
        const rectBefore = domElementBefore ? new Rect(domElementBefore) : null;
        const rectAfter = domElementAfter ? new Rect(domElementAfter) : null;
        const rectParent = new Rect(domElementParent).excludeScrollbarsAndBorders();
        const above = rectBefore ? rectBefore.bottom : rectParent.top;
        const below = rectAfter ? rectAfter.top : rectParent.bottom;
        const parentStyle = global.window.getComputedStyle(domElementParent);
        const top = (above <= below ? (above + below) / 2 : below);
        if (domScrollableRect.top < top && top < domScrollableRect.bottom) {
            const left = rectParent.left + parseFloat(parentStyle.paddingLeft);
            const right = rectParent.right - parseFloat(parentStyle.paddingRight);
            const leftClamped = Math.max(left + scrollX, domScrollableRect.left);
            const rightClamped = Math.min(right + scrollX, domScrollableRect.right);
            this._dropTargetLineView.set({
                isVisible: true,
                left: leftClamped,
                top: top + scrollY,
                width: rightClamped - leftClamped
            });
        }
        else {
            this._dropTargetLineView.isVisible = false;
        }
    }
    /**
     * Finds the closest scrollable element rect for the given view element.
     */
    _getScrollableRect(viewElement) {
        const rootName = viewElement.root.rootName;
        let domScrollable;
        if (this._scrollables.has(rootName)) {
            domScrollable = this._scrollables.get(rootName).domElement;
        }
        else {
            const domElement = this.editor.editing.view.domConverter.mapViewToDom(viewElement);
            domScrollable = findScrollableElement(domElement);
            this._domEmitter.listenTo(domScrollable, 'scroll', this._reconvertMarkerThrottled, { usePassive: true });
            const resizeObserver = new ResizeObserver(domScrollable, this._reconvertMarkerThrottled);
            this._scrollables.set(rootName, {
                domElement: domScrollable,
                resizeObserver
            });
        }
        return new Rect(domScrollable).excludeScrollbarsAndBorders();
    }
}
/**
 * Returns fixed selection range for given position and target element.
 */
function findDropTargetRange(editor, targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange) {
    const model = editor.model;
    const mapper = editor.editing.mapper;
    const targetModelElement = getClosestMappedModelElement(editor, targetViewElement);
    let modelElement = targetModelElement;
    while (modelElement) {
        if (!blockMode) {
            if (model.schema.checkChild(modelElement, '$text')) {
                if (targetViewRanges) {
                    const targetViewPosition = targetViewRanges[0].start;
                    const targetModelPosition = mapper.toModelPosition(targetViewPosition);
                    const canDropOnPosition = !draggedRange || Array
                        .from(draggedRange.getItems())
                        .every(item => model.schema.checkChild(targetModelPosition, item));
                    if (canDropOnPosition) {
                        if (model.schema.checkChild(targetModelPosition, '$text')) {
                            return model.createRange(targetModelPosition);
                        }
                        else if (targetViewPosition) {
                            // This is the case of dropping inside a span wrapper of an inline image.
                            return findDropTargetRangeForElement(editor, getClosestMappedModelElement(editor, targetViewPosition.parent), clientX, clientY);
                        }
                    }
                }
            }
            else if (model.schema.isInline(modelElement)) {
                return findDropTargetRangeForElement(editor, modelElement, clientX, clientY);
            }
        }
        if (model.schema.isBlock(modelElement)) {
            return findDropTargetRangeForElement(editor, modelElement, clientX, clientY);
        }
        else if (model.schema.checkChild(modelElement, '$block')) {
            const childNodes = Array.from(modelElement.getChildren())
                .filter((node) => node.is('element') && !shouldIgnoreElement(editor, node));
            let startIndex = 0;
            let endIndex = childNodes.length;
            if (endIndex == 0) {
                return model.createRange(model.createPositionAt(modelElement, 'end'));
            }
            while (startIndex < endIndex - 1) {
                const middleIndex = Math.floor((startIndex + endIndex) / 2);
                const side = findElementSide(editor, childNodes[middleIndex], clientX, clientY);
                if (side == 'before') {
                    endIndex = middleIndex;
                }
                else {
                    startIndex = middleIndex;
                }
            }
            return findDropTargetRangeForElement(editor, childNodes[startIndex], clientX, clientY);
        }
        modelElement = modelElement.parent;
    }
    return null;
}
/**
 * Returns true for elements which should be ignored.
 */
function shouldIgnoreElement(editor, modelElement) {
    const mapper = editor.editing.mapper;
    const domConverter = editor.editing.view.domConverter;
    const viewElement = mapper.toViewElement(modelElement);
    if (!viewElement) {
        return true;
    }
    const domElement = domConverter.mapViewToDom(viewElement);
    return global.window.getComputedStyle(domElement).float != 'none';
}
/**
 * Returns target range relative to the given element.
 */
function findDropTargetRangeForElement(editor, modelElement, clientX, clientY) {
    const model = editor.model;
    return model.createRange(model.createPositionAt(modelElement, findElementSide(editor, modelElement, clientX, clientY)));
}
/**
 * Resolves whether drop marker should be before or after the given element.
 */
function findElementSide(editor, modelElement, clientX, clientY) {
    const mapper = editor.editing.mapper;
    const domConverter = editor.editing.view.domConverter;
    const viewElement = mapper.toViewElement(modelElement);
    const domElement = domConverter.mapViewToDom(viewElement);
    const rect = new Rect(domElement);
    if (editor.model.schema.isInline(modelElement)) {
        return clientX < (rect.left + rect.right) / 2 ? 'before' : 'after';
    }
    else {
        return clientY < (rect.top + rect.bottom) / 2 ? 'before' : 'after';
    }
}
/**
 * Returns the closest model element for the specified view element.
 */
function getClosestMappedModelElement(editor, element) {
    const mapper = editor.editing.mapper;
    const view = editor.editing.view;
    const targetModelElement = mapper.toModelElement(element);
    if (targetModelElement) {
        return targetModelElement;
    }
    // Find mapped ancestor if the target is inside not mapped element (for example inline code element).
    const viewPosition = view.createPositionBefore(element);
    const viewElement = mapper.findMappedViewAncestor(viewPosition);
    return mapper.toModelElement(viewElement);
}
/**
 * Returns the closest scrollable ancestor DOM element.
 *
 * It is assumed that `domNode` is attached to the document.
 */
function findScrollableElement(domNode) {
    let domElement = domNode;
    do {
        domElement = domElement.parentElement;
        const overflow = global.window.getComputedStyle(domElement).overflowY;
        if (overflow == 'auto' || overflow == 'scroll') {
            break;
        }
    } while (domElement.tagName != 'BODY');
    return domElement;
}
