/**
 * @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 typing/twostepcaretmovement
 */
import { Plugin } from '@ckeditor/ckeditor5-core';
import { keyCodes } from '@ckeditor/ckeditor5-utils';
import { MouseObserver } from '@ckeditor/ckeditor5-engine';
/**
 * This plugin enables the two-step caret (phantom) movement behavior for
 * {@link module:typing/twostepcaretmovement~TwoStepCaretMovement#registerAttribute registered attributes}
 * on arrow right (<kbd>→</kbd>) and left (<kbd>←</kbd>) key press.
 *
 * Thanks to this (phantom) caret movement the user is able to type before/after as well as at the
 * beginning/end of an attribute.
 *
 * **Note:** This plugin support right–to–left (Arabic, Hebrew, etc.) content by mirroring its behavior
 * but for the sake of simplicity examples showcase only left–to–right use–cases.
 *
 * # Forward movement
 *
 * ## "Entering" an attribute:
 *
 * When this plugin is enabled and registered for the `a` attribute and the selection is right before it
 * (at the attribute boundary), pressing the right arrow key will not move the selection but update its
 * attributes accordingly:
 *
 * * When enabled:
 *
 * ```xml
 * foo{}<$text a="true">bar</$text>
 * ```
 *
 * 	<kbd>→</kbd>
 *
 * ```xml
 * foo<$text a="true">{}bar</$text>
 * ```
 *
 * * When disabled:
 *
 * ```xml
 * foo{}<$text a="true">bar</$text>
 * ```
 *
 * 	<kbd>→</kbd>
 *
 * ```xml
 * foo<$text a="true">b{}ar</$text>
 * ```
 *
 *
 * ## "Leaving" an attribute:
 *
 * * When enabled:
 *
 * ```xml
 * <$text a="true">bar{}</$text>baz
 * ```
 *
 * 	<kbd>→</kbd>
 *
 * ```xml
 * <$text a="true">bar</$text>{}baz
 * ```
 *
 * * When disabled:
 *
 * ```xml
 * <$text a="true">bar{}</$text>baz
 * ```
 *
 * 	<kbd>→</kbd>
 *
 * ```xml
 * <$text a="true">bar</$text>b{}az
 * ```
 *
 * # Backward movement
 *
 * * When enabled:
 *
 * ```xml
 * <$text a="true">bar</$text>{}baz
 * ```
 *
 * 	<kbd>←</kbd>
 *
 * ```xml
 * <$text a="true">bar{}</$text>baz
 * ```
 *
 * * When disabled:
 *
 * ```xml
 * <$text a="true">bar</$text>{}baz
 * ```
 *
 * 	<kbd>←</kbd>
 *
 * ```xml
 * <$text a="true">ba{}r</$text>b{}az
 * ```
 *
 * # Multiple attributes
 *
 * * When enabled and many attributes starts or ends at the same position:
 *
 * ```xml
 * <$text a="true" b="true">bar</$text>{}baz
 * ```
 *
 * 	<kbd>←</kbd>
 *
 * ```xml
 * <$text a="true" b="true">bar{}</$text>baz
 * ```
 *
 * * When enabled and one procedes another:
 *
 * ```xml
 * <$text a="true">bar</$text><$text b="true">{}bar</$text>
 * ```
 *
 * 	<kbd>←</kbd>
 *
 * ```xml
 * <$text a="true">bar{}</$text><$text b="true">bar</$text>
 * ```
 *
 */
export default class TwoStepCaretMovement extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'TwoStepCaretMovement';
    }
    /**
     * @inheritDoc
     */
    constructor(editor) {
        super(editor);
        /**
         * A flag indicating that the automatic gravity restoration should not happen upon the next
         * gravity restoration.
         * {@link module:engine/model/selection~Selection#event:change:range} event.
         */
        this._isNextGravityRestorationSkipped = false;
        this.attributes = new Set();
        this._overrideUid = null;
    }
    /**
     * @inheritDoc
     */
    init() {
        const editor = this.editor;
        const model = editor.model;
        const view = editor.editing.view;
        const locale = editor.locale;
        const modelSelection = model.document.selection;
        // Listen to keyboard events and handle the caret movement according to the 2-step caret logic.
        this.listenTo(view.document, 'arrowKey', (evt, data) => {
            // This implementation works only for collapsed selection.
            if (!modelSelection.isCollapsed) {
                return;
            }
            // When user tries to expand the selection or jump over the whole word or to the beginning/end then
            // two-steps movement is not necessary.
            if (data.shiftKey || data.altKey || data.ctrlKey) {
                return;
            }
            const arrowRightPressed = data.keyCode == keyCodes.arrowright;
            const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
            // When neither left or right arrow has been pressed then do noting.
            if (!arrowRightPressed && !arrowLeftPressed) {
                return;
            }
            const contentDirection = locale.contentLanguageDirection;
            let isMovementHandled = false;
            if ((contentDirection === 'ltr' && arrowRightPressed) || (contentDirection === 'rtl' && arrowLeftPressed)) {
                isMovementHandled = this._handleForwardMovement(data);
            }
            else {
                isMovementHandled = this._handleBackwardMovement(data);
            }
            // Stop the keydown event if the two-step caret movement handled it. Avoid collisions
            // with other features which may also take over the caret movement (e.g. Widget).
            if (isMovementHandled === true) {
                evt.stop();
            }
        }, { context: '$text', priority: 'highest' });
        // The automatic gravity restoration logic.
        this.listenTo(modelSelection, 'change:range', (evt, data) => {
            // Skipping the automatic restoration is needed if the selection should change
            // but the gravity must remain overridden afterwards. See the #handleBackwardMovement
            // to learn more.
            if (this._isNextGravityRestorationSkipped) {
                this._isNextGravityRestorationSkipped = false;
                return;
            }
            // Skip automatic restore when the gravity is not overridden — simply, there's nothing to restore
            // at this moment.
            if (!this._isGravityOverridden) {
                return;
            }
            // Skip automatic restore when the change is indirect AND the selection is at the attribute boundary.
            // It means that e.g. if the change was external (collaboration) and the user had their
            // selection around the link, its gravity should remain intact in this change:range event.
            if (!data.directChange && isBetweenDifferentAttributes(modelSelection.getFirstPosition(), this.attributes)) {
                return;
            }
            this._restoreGravity();
        });
        // Handle a click at the beginning/end of a two-step element.
        this._enableClickingAfterNode();
        // Change the attributes of the selection in certain situations after the two-step node was inserted into the document.
        this._enableInsertContentSelectionAttributesFixer();
        // Handle removing the content after the two-step node.
        this._handleDeleteContentAfterNode();
    }
    /**
     * Registers a given attribute for the two-step caret movement.
     *
     * @param attribute Name of the attribute to handle.
     */
    registerAttribute(attribute) {
        this.attributes.add(attribute);
    }
    /**
     * Updates the document selection and the view according to the two–step caret movement state
     * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
     *
     * @param data Data of the key press.
     * @returns `true` when the handler prevented caret movement.
     */
    _handleForwardMovement(data) {
        const attributes = this.attributes;
        const model = this.editor.model;
        const selection = model.document.selection;
        const position = selection.getFirstPosition();
        // DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
        //
        // 		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
        //
        // or left the attribute
        //
        // 		<paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
        //
        // and the gravity will be restored automatically.
        if (this._isGravityOverridden) {
            return false;
        }
        // DON'T ENGAGE 2-SCM when the selection is at the beginning of the block AND already has the
        // attribute:
        // * when the selection was initially set there using the mouse,
        // * when the editor has just started
        //
        //		<paragraph><$text attribute>{}bar</$text>baz</paragraph>
        //
        if (position.isAtStart && hasAnyAttribute(selection, attributes)) {
            return false;
        }
        // ENGAGE 2-SCM When at least one of the observed attributes changes its value (incl. starts, ends).
        //
        //		<paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
        //		<paragraph>foo<$text attribute>bar{}</$text><$text otherAttribute>baz</$text></paragraph>
        //		<paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>baz</$text></paragraph>
        //		<paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
        //
        if (isBetweenDifferentAttributes(position, attributes)) {
            preventCaretMovement(data);
            // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before
            // the next one with a different value of the same attribute.
            //
            //		<paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>bar</$text>baz</paragraph>
            //
            if (hasAnyAttribute(selection, attributes) &&
                isBetweenDifferentAttributes(position, attributes, true)) {
                clearSelectionAttributes(model, attributes);
            }
            else {
                this._overrideGravity();
            }
            return true;
        }
        return false;
    }
    /**
     * Updates the document selection and the view according to the two–step caret movement state
     * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
     *
     * @param data Data of the key press.
     * @returns `true` when the handler prevented caret movement
     */
    _handleBackwardMovement(data) {
        const attributes = this.attributes;
        const model = this.editor.model;
        const selection = model.document.selection;
        const position = selection.getFirstPosition();
        // When the gravity is already overridden (by this plugin), it means we are on the two-step position.
        // Prevent the movement, restore the gravity and update selection attributes.
        //
        //		<paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}baz</$text></paragraph>
        //		<paragraph>foo<$text attribute>bar</$text><$text otherAttribute>{}baz</$text></paragraph>
        //		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
        //		<paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
        //
        if (this._isGravityOverridden) {
            preventCaretMovement(data);
            this._restoreGravity();
            // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before
            // the next one with a different value of the same attribute.
            //
            //		<paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}bar</$text>baz</paragraph>
            //
            if (isBetweenDifferentAttributes(position, attributes, true)) {
                clearSelectionAttributes(model, attributes);
            }
            else {
                setSelectionAttributesFromTheNodeBefore(model, attributes, position);
            }
            return true;
        }
        else {
            // REMOVE SELECTION ATTRIBUTE when restoring gravity towards a non-existent content at the
            // beginning of the block.
            //
            // 		<paragraph>{}<$text attribute>bar</$text></paragraph>
            //
            if (position.isAtStart) {
                if (hasAnyAttribute(selection, attributes)) {
                    preventCaretMovement(data);
                    setSelectionAttributesFromTheNodeBefore(model, attributes, position);
                    return true;
                }
                return false;
            }
            // SET 2-SCM attributes if we are between nodes with the same attribute but with different values.
            //
            //		<paragraph>foo<$text attribute=1>bar</$text>[]<$text attribute=2>bar</$text>baz</paragraph>
            //
            if (!hasAnyAttribute(selection, attributes) &&
                isBetweenDifferentAttributes(position, attributes, true)) {
                preventCaretMovement(data);
                setSelectionAttributesFromTheNodeBefore(model, attributes, position);
                return true;
            }
            // When we are moving from natural gravity, to the position of the 2SCM, we need to override the gravity,
            // and make sure it won't be restored. Unless it's at the end of the block and an observed attribute.
            // We need to check if the caret is a one position before the attribute boundary:
            //
            //		<paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>b{}az</$text></paragraph>
            //		<paragraph>foo<$text attribute>bar</$text><$text otherAttribute>b{}az</$text></paragraph>
            //		<paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
            //		<paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
            //
            if (isStepAfterAnyAttributeBoundary(position, attributes)) {
                // ENGAGE 2-SCM if the selection has no attribute. This may happen when the user
                // left the attribute using a FORWARD 2-SCM.
                //
                // 		<paragraph><$text attribute>bar</$text>{}</paragraph>
                //
                if (position.isAtEnd &&
                    !hasAnyAttribute(selection, attributes) &&
                    isBetweenDifferentAttributes(position, attributes)) {
                    preventCaretMovement(data);
                    setSelectionAttributesFromTheNodeBefore(model, attributes, position);
                    return true;
                }
                // Skip the automatic gravity restore upon the next selection#change:range event.
                // If not skipped, it would automatically restore the gravity, which should remain
                // overridden.
                this._isNextGravityRestorationSkipped = true;
                this._overrideGravity();
                // Don't return "true" here because we didn't call _preventCaretMovement.
                // Returning here will destabilize the filler logic, which also listens to
                // keydown (and the event would be stopped).
                return false;
            }
        }
        return false;
    }
    /**
     * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and
     * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a 2-step node
     * if clicked at the beginning/ending of the 2-step node.
     *
     * The purpose of this action is to allow typing around the 2-step node directly after a click.
     *
     * See https://github.com/ckeditor/ckeditor5/issues/1016.
     */
    _enableClickingAfterNode() {
        const editor = this.editor;
        const model = editor.model;
        const selection = model.document.selection;
        const document = editor.editing.view.document;
        editor.editing.view.addObserver(MouseObserver);
        let clicked = false;
        // Detect the click.
        this.listenTo(document, 'mousedown', () => {
            clicked = true;
        });
        // When the selection has changed...
        this.listenTo(document, 'selectionChange', () => {
            const attributes = this.attributes;
            if (!clicked) {
                return;
            }
            // ...and it was caused by the click...
            clicked = false;
            // ...and no text is selected...
            if (!selection.isCollapsed) {
                return;
            }
            // ...and clicked text is the 2-step node...
            if (!hasAnyAttribute(selection, attributes)) {
                return;
            }
            const position = selection.getFirstPosition();
            if (!isBetweenDifferentAttributes(position, attributes)) {
                return;
            }
            // The selection at the start of a block would use surrounding attributes
            // from text after the selection so just clear 2-SCM attributes.
            //
            // Also, clear attributes for selection between same attribute with different values.
            if (position.isAtStart ||
                isBetweenDifferentAttributes(position, attributes, true)) {
                clearSelectionAttributes(model, attributes);
            }
            else if (!this._isGravityOverridden) {
                this._overrideGravity();
            }
        });
    }
    /**
     * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
     * selection attributes if the selection is at the end of a two-step node after inserting the content.
     *
     * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the
     * two-step attribute of the selection, and they can type a "clean" (`linkHref`–less) text right away.
     *
     * See https://github.com/ckeditor/ckeditor5/issues/6053.
     */
    _enableInsertContentSelectionAttributesFixer() {
        const editor = this.editor;
        const model = editor.model;
        const selection = model.document.selection;
        const attributes = this.attributes;
        this.listenTo(model, 'insertContent', () => {
            const position = selection.getFirstPosition();
            if (hasAnyAttribute(selection, attributes) &&
                isBetweenDifferentAttributes(position, attributes)) {
                clearSelectionAttributes(model, attributes);
            }
        }, { priority: 'low' });
    }
    /**
     * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether
     * removing a content right after the tow-step attribute.
     *
     * If so, the selection should not preserve the two-step attribute. However, if
     * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and
     * the selection has the two-step attribute due to overridden gravity (at the end), the two-step attribute should stay untouched.
     *
     * The purpose of this action is to allow removing the link text and keep the selection outside the link.
     *
     * See https://github.com/ckeditor/ckeditor5/issues/7521.
     */
    _handleDeleteContentAfterNode() {
        const editor = this.editor;
        const model = editor.model;
        const selection = model.document.selection;
        const view = editor.editing.view;
        let isBackspace = false;
        let shouldPreserveAttributes = false;
        // Detect pressing `Backspace`.
        this.listenTo(view.document, 'delete', (evt, data) => {
            isBackspace = data.direction === 'backward';
        }, { priority: 'high' });
        // Before removing the content, check whether the selection is inside a two-step attribute.
        // If so, we want to preserve those attributes.
        this.listenTo(model, 'deleteContent', () => {
            if (!isBackspace) {
                return;
            }
            const position = selection.getFirstPosition();
            shouldPreserveAttributes = hasAnyAttribute(selection, this.attributes) &&
                !isStepAfterAnyAttributeBoundary(position, this.attributes);
        }, { priority: 'high' });
        // After removing the content, check whether the current selection should preserve the `linkHref` attribute.
        this.listenTo(model, 'deleteContent', () => {
            if (!isBackspace) {
                return;
            }
            isBackspace = false;
            // Do not escape two-step attribute if it was inside it before content deletion.
            if (shouldPreserveAttributes) {
                return;
            }
            // Use `model.enqueueChange()` in order to execute the callback at the end of the changes process.
            editor.model.enqueueChange(() => {
                const position = selection.getFirstPosition();
                if (hasAnyAttribute(selection, this.attributes) &&
                    isBetweenDifferentAttributes(position, this.attributes)) {
                    if (position.isAtStart || isBetweenDifferentAttributes(position, this.attributes, true)) {
                        clearSelectionAttributes(model, this.attributes);
                    }
                    else if (!this._isGravityOverridden) {
                        this._overrideGravity();
                    }
                }
            });
        }, { priority: 'low' });
    }
    /**
     * `true` when the gravity is overridden for the plugin.
     */
    get _isGravityOverridden() {
        return !!this._overrideUid;
    }
    /**
     * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer}
     * and stores the information about this fact in the {@link #_overrideUid}.
     *
     * A shorthand for {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
     */
    _overrideGravity() {
        this._overrideUid = this.editor.model.change(writer => {
            return writer.overrideSelectionGravity();
        });
    }
    /**
     * Restores the gravity using the {@link module:engine/model/writer~Writer model writer}.
     *
     * A shorthand for {@link module:engine/model/writer~Writer#restoreSelectionGravity}.
     */
    _restoreGravity() {
        this.editor.model.change(writer => {
            writer.restoreSelectionGravity(this._overrideUid);
            this._overrideUid = null;
        });
    }
}
/**
 * Checks whether the selection has any of given attributes.
 */
function hasAnyAttribute(selection, attributes) {
    for (const observedAttribute of attributes) {
        if (selection.hasAttribute(observedAttribute)) {
            return true;
        }
    }
    return false;
}
/**
 * Applies the given attributes to the current selection using using the
 * values from the node before the current position. Uses
 * the {@link module:engine/model/writer~Writer model writer}.
 */
function setSelectionAttributesFromTheNodeBefore(model, attributes, position) {
    const nodeBefore = position.nodeBefore;
    model.change(writer => {
        if (nodeBefore) {
            const attributes = [];
            const isInlineObject = model.schema.isObject(nodeBefore) && model.schema.isInline(nodeBefore);
            for (const [key, value] of nodeBefore.getAttributes()) {
                if (model.schema.checkAttribute('$text', key) &&
                    (!isInlineObject || model.schema.getAttributeProperties(key).copyFromObject !== false)) {
                    attributes.push([key, value]);
                }
            }
            writer.setSelectionAttribute(attributes);
        }
        else {
            writer.removeSelectionAttribute(attributes);
        }
    });
}
/**
 * Removes 2-SCM attributes from the selection.
 */
function clearSelectionAttributes(model, attributes) {
    model.change(writer => {
        writer.removeSelectionAttribute(attributes);
    });
}
/**
 * Prevents the caret movement in the view by calling `preventDefault` on the event data.
 *
 * @alias data.preventDefault
 */
function preventCaretMovement(data) {
    data.preventDefault();
}
/**
 * Checks whether the step before `isBetweenDifferentAttributes()`.
 */
function isStepAfterAnyAttributeBoundary(position, attributes) {
    const positionBefore = position.getShiftedBy(-1);
    return isBetweenDifferentAttributes(positionBefore, attributes);
}
/**
 * Checks whether the given position is between different values of given attributes.
 */
function isBetweenDifferentAttributes(position, attributes, isStrict = false) {
    const { nodeBefore, nodeAfter } = position;
    for (const observedAttribute of attributes) {
        const attrBefore = nodeBefore ? nodeBefore.getAttribute(observedAttribute) : undefined;
        const attrAfter = nodeAfter ? nodeAfter.getAttribute(observedAttribute) : undefined;
        if (isStrict && (attrBefore === undefined || attrAfter === undefined)) {
            continue;
        }
        if (attrAfter !== attrBefore) {
            return true;
        }
    }
    return false;
}
