dokuwiki/lib/scripts/linkwiz.js

/* global jQuery, DOKU_BASE, DOKU_UHC, JSINFO, LANG, DWgetSelection, pasteText */

/**
 * The Link Wizard
 *
 * @author Andreas Gohr <gohr@cosmocode.de>
 * @author Pierre Spring <pierre.spring@caillou.ch>
 */
class LinkWizard {

    /** @var {jQuery} $wiz The wizard dialog */
    $wiz = null;
    /** @var {jQuery} $entry The input field to interact with the wizard*/
    $entry = null;
    /** @var {HTMLDivElement} result The result output div */
    result = null;
    /** @var {TimerHandler} timer Used to debounce the autocompletion */
    timer = null;
    /** @var {HTMLTextAreaElement} textArea The text area of the editor into which links are inserted */
    textArea = null;
    /** @var {int} selected The index of the currently selected object in the result list */
    selected = -1;
    /** @var {Object} selection A DokuWiki selection object holding text positions in the editor */
    selection = null;
    /** @var {Object} val The syntax used. See 935ecb0ef751ac1d658932316e06410e70c483e0 */
    val = {
        open: '[[',
        close: ']]'
    };

    /**
     * Initialize the LinkWizard by creating the needed HTML
     * and attaching the event handlers
     */
    init($editor) {
        // position relative to the text area
        const pos = $editor.position();

        // create HTML Structure
        if (this.$wiz) return;
        this.$wiz = jQuery(document.createElement('div'))
            .dialog({
                autoOpen: false,
                draggable: true,
                title: LANG.linkwiz,
                resizable: false
            })
            .html(
                '<div>' + LANG.linkto + ' <input type="text" class="edit" id="link__wiz_entry" autocomplete="off" /></div>' +
                '<div id="link__wiz_result"></div>'
            )
            .parent()
            .attr('id', 'link__wiz')
            .css({
                'position': 'absolute',
                'top': (pos.top + 20) + 'px',
                'left': (pos.left + 80) + 'px'
            })
            .hide()
            .appendTo('.dokuwiki:first');

        this.textArea = $editor[0];
        this.result = jQuery('#link__wiz_result')[0];

        // scrollview correction on arrow up/down gets easier
        jQuery(this.result).css('position', 'relative');

        this.$entry = jQuery('#link__wiz_entry');
        if (JSINFO.namespace) {
            this.$entry.val(JSINFO.namespace + ':');
        }

        // attach event handlers
        jQuery('#link__wiz .ui-dialog-titlebar-close').on('click', () => this.hide());
        this.$entry.keyup((e) => this.onEntry(e));
        jQuery(this.result).on('click', 'a', (e) => this.onResultClick(e));
    }

    /**
     * Handle all keyup events in the entry field
     */
    onEntry(e) {
        if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { //left/right
            return true; //ignore
        }
        if (e.key === 'Escape') { //Escape
            this.hide();
            e.preventDefault();
            e.stopPropagation();
            return false;
        }
        if (e.key === 'ArrowUp') { //Up
            this.select(this.selected - 1);
            e.preventDefault();
            e.stopPropagation();
            return false;
        }
        if (e.key === 'ArrowDown') { //Down
            this.select(this.selected + 1);
            e.preventDefault();
            e.stopPropagation();
            return false;
        }
        if (e.key === 'Enter') { //Enter
            if (this.selected > -1) {
                const $obj = this.$getResult(this.selected);
                if ($obj.length > 0) {
                    this.resultClick($obj.find('a')[0]);
                }
            } else if (this.$entry.val()) {
                this.insertLink(this.$entry.val());
            }

            e.preventDefault();
            e.stopPropagation();
            return false;
        }
        this.autocomplete();
    }

    /**
     * Get one of the results by index
     *
     * @param   num int result div to return
     * @returns jQuery object
     */
    $getResult(num) {
        return jQuery(this.result).find('div').eq(num);
    }

    /**
     * Select the given result
     */
    select(num) {
        if (num < 0) {
            this.deselect();
            return;
        }

        const $obj = this.$getResult(num);
        if ($obj.length === 0) {
            return;
        }

        this.deselect();
        $obj.addClass('selected');

        // make sure the item is viewable in the scroll view

        //getting child position within the parent
        const childPos = $obj.position().top;
        //getting difference between the childs top and parents viewable area
        const yDiff = childPos + $obj.outerHeight() - jQuery(this.result).innerHeight();

        if (childPos < 0) {
            //if childPos is above viewable area (that's why it goes negative)
            jQuery(this.result)[0].scrollTop += childPos;
        } else if (yDiff > 0) {
            // if difference between childs top and parents viewable area is
            // greater than the height of a childDiv
            jQuery(this.result)[0].scrollTop += yDiff;
        }

        this.selected = num;
    }

    /**
     * Deselect a result if any is selected
     */
    deselect() {
        if (this.selected > -1) {
            this.$getResult(this.selected).removeClass('selected');
        }
        this.selected = -1;
    }

    /**
     * Handle clicks in the result set and dispatch them to
     * resultClick()
     */
    onResultClick(e) {
        if (!jQuery(e.target).is('a')) {
            return;
        }
        e.stopPropagation();
        e.preventDefault();
        this.resultClick(e.target);
        return false;
    }

    /**
     * Handles the "click" on a given result anchor
     */
    resultClick(a) {
        this.$entry.val(a.title);
        if (a.title === '' || a.title.charAt(a.title.length - 1) === ':') {
            this.autocomplete_exec();
        } else {
            if (jQuery(a.nextSibling).is('span')) {
                this.insertLink(a.nextSibling.innerText);
            } else {
                this.insertLink('');
            }
        }
    }

    /**
     * Insert the id currently in the entry box to the textarea,
     * replacing the current selection or at the cursor position.
     * When no selection is available the given title will be used
     * as link title instead
     *
     * @param {string} title The heading text to use as link title if configured
     */
    insertLink(title) {
        let link = this.$entry.val();
        let selection;
        let linkTitle;
        if (!link) {
            return;
        }

        // use the current selection, if not available use the one that was stored when the wizard was opened
        selection = DWgetSelection(this.textArea);
        if (selection.start === 0 && selection.end === 0) {
            selection = this.selection;
        }

        // if the selection has any text, use it as the link title
        linkTitle = selection.getText();
        if (linkTitle.charAt(linkTitle.length - 1) === ' ') {
            // don't include trailing space in selection
            selection.end--;
            linkTitle = selection.getText();
        }

        // if there is no selection, and useheading is enabled, use the heading text as the link title
        if (!linkTitle && !DOKU_UHC) {
            linkTitle = title;
        }

        // paste the link
        const syntax = this.createLinkSyntax(link, linkTitle);
        pasteText(selection, syntax.link, syntax);
        this.hide();

        // reset the entry to the parent namespace
        const externallinkpattern = new RegExp('^((f|ht)tps?:)?//', 'i');
        let entry_value;
        if (externallinkpattern.test(this.$entry.val())) {
            if (JSINFO.namespace) {
                entry_value = JSINFO.namespace + ':';
            } else {
                entry_value = ''; //reset whole external links
            }
        } else {
            entry_value = this.$entry.val().replace(/[^:]*$/, '')
        }
        this.$entry.val(entry_value);
    }

    /**
     * Constructs the full syntax and calculates offsets
     *
     * @param {string} id
     * @param {string} title
     * @returns {{link: string, startofs: number, endofs: number }}
     */
    createLinkSyntax(id, title) {
        // construct a relative link, except for external links
        let link = id;
        if (!id.match(/^(f|ht)tps?:\/\//i)) {
            const refId = this.textArea.form.id.value;
            link = LinkWizard.createRelativeID(refId, id);
        }

        let startofs = link.length;
        let endofs = 0;

        if (this.val.open) {
            startofs += this.val.open.length;
            link = this.val.open + link;
        }
        link += '|';
        startofs += 1;
        if (title) {
            link += title;
        }
        if (this.val.close) {
            link += this.val.close;
            endofs = this.val.close.length;
        }

        return {link, startofs, endofs};
    }

    /**
     * Start the page/namespace lookup timer
     *
     * Calls autocomplete_exec when the timer runs out
     */
    autocomplete() {
        if (this.timer !== null) {
            window.clearTimeout(this.timer);
            this.timer = null;
        }

        this.timer = window.setTimeout(() => this.autocomplete_exec(), 350);
    }

    /**
     * Executes the AJAX call for the page/namespace lookup
     */
    autocomplete_exec() {
        const $res = jQuery(this.result);
        this.deselect();
        $res.html('<img src="' + DOKU_BASE + 'lib/images/throbber.gif" alt="" width="16" height="16" />')
            .load(
                DOKU_BASE + 'lib/exe/ajax.php',
                {
                    call: 'linkwiz',
                    q: this.$entry.val()
                }
            );
    }

    /**
     * Show the link wizard
     */
    show() {
        this.selection = DWgetSelection(this.textArea);
        this.$wiz.show();
        this.$entry.focus();
        this.autocomplete();

        // Move the cursor to the end of the input
        const temp = this.$entry.val();
        this.$entry.val('');
        this.$entry.val(temp);
    }

    /**
     * Hide the link wizard
     */
    hide() {
        this.$wiz.hide();
        this.textArea.focus();
    }

    /**
     * Toggle the link wizard
     */
    toggle() {
        if (this.$wiz.css('display') === 'none') {
            this.show();
        } else {
            this.hide();
        }
    }

    /**
     * Create a relative ID from a given reference ID and a full ID to link to
     *
     * Both IDs are expected to be clean, (eg. the result of cleanID()). No relative paths,
     * leading colons or similar things are alowed. As long as pages have a common prefix,
     * a relative link is constructed.
     *
     * This method is static and meant to be reused by other scripts if needed.
     *
     * @todo does currently not create page relative links using ~
     * @param {string} ref The ID of a page the link is used on
     * @param {string} id The ID to link to
     */
    static createRelativeID(ref, id) {
        const sourceNs = ref.split(':');
        [/*sourcePage*/] = sourceNs.pop();
        const targetNs = id.split(':');
        const targetPage = targetNs.pop();
        const relativeID = [];

        // Find the common prefix length
        let commonPrefixLength = 0;
        while (
            commonPrefixLength < sourceNs.length &&
            commonPrefixLength < targetNs.length &&
            sourceNs[commonPrefixLength] === targetNs[commonPrefixLength]
            ) {
            commonPrefixLength++;
        }


        if (sourceNs.length) {
            // special treatment is only needed when the reference is a namespaced page
            if (commonPrefixLength) {
                if (commonPrefixLength === sourceNs.length && commonPrefixLength === targetNs.length) {
                    // both pages are in the same namespace
                    // link consists of simple page only
                    // namespaces are irrelevant
                } else if (commonPrefixLength < sourceNs.length) {
                    // add .. for each missing namespace from common to the target
                    relativeID.push(...Array(sourceNs.length - commonPrefixLength).fill('..'));
                } else {
                    // target is below common prefix, add .
                    relativeID.push('.');
                }
            } else if (targetNs.length === 0) {
                // target is in the root namespace, but source is not, make it absolute
                relativeID.push('');
            }
            // add any remaining parts of targetNS
            relativeID.push(...targetNs.slice(commonPrefixLength));
        } else {
            // source is in the root namespace, just use target as is
            relativeID.push(...targetNs);
        }

        // add targetPage
        relativeID.push(targetPage);

        return relativeID.join(':');
    }

}

window.dw_linkwiz = new LinkWizard();