1/* global jQuery, DOKU_BASE, DOKU_UHC, JSINFO, LANG, DWgetSelection, pasteText */
2
3/**
4 * The Link Wizard
5 *
6 * @author Andreas Gohr <gohr@cosmocode.de>
7 * @author Pierre Spring <pierre.spring@caillou.ch>
8 */
9class LinkWizard {
10
11    /** @var {jQuery} $wiz The wizard dialog */
12    $wiz = null;
13    /** @var {jQuery} $entry The input field to interact with the wizard*/
14    $entry = null;
15    /** @var {HTMLDivElement} result The result output div */
16    result = null;
17    /** @var {TimerHandler} timer Used to debounce the autocompletion */
18    timer = null;
19    /** @var {HTMLTextAreaElement} textArea The text area of the editor into which links are inserted */
20    textArea = null;
21    /** @var {int} selected The index of the currently selected object in the result list */
22    selected = -1;
23    /** @var {Object} selection A DokuWiki selection object holding text positions in the editor */
24    selection = null;
25    /** @var {Object} val The syntax used. See 935ecb0ef751ac1d658932316e06410e70c483e0 */
26    val = {
27        open: '[[',
28        close: ']]'
29    };
30
31    /**
32     * Initialize the LinkWizard by creating the needed HTML
33     * and attaching the event handlers
34     */
35    init($editor) {
36        // position relative to the text area
37        const pos = $editor.position();
38
39        // create HTML Structure
40        if (this.$wiz) return;
41        this.$wiz = jQuery(document.createElement('div'))
42            .dialog({
43                autoOpen: false,
44                draggable: true,
45                title: LANG.linkwiz,
46                resizable: false
47            })
48            .html(
49                '<div>' + LANG.linkto + ' <input type="text" class="edit" id="link__wiz_entry" autocomplete="off" /></div>' +
50                '<div id="link__wiz_result"></div>'
51            )
52            .parent()
53            .attr('id', 'link__wiz')
54            .css({
55                'position': 'absolute',
56                'top': (pos.top + 20) + 'px',
57                'left': (pos.left + 80) + 'px'
58            })
59            .hide()
60            .appendTo('.dokuwiki:first');
61
62        this.textArea = $editor[0];
63        this.result = jQuery('#link__wiz_result')[0];
64
65        // scrollview correction on arrow up/down gets easier
66        jQuery(this.result).css('position', 'relative');
67
68        this.$entry = jQuery('#link__wiz_entry');
69        if (JSINFO.namespace) {
70            this.$entry.val(JSINFO.namespace + ':');
71        }
72
73        // attach event handlers
74        jQuery('#link__wiz .ui-dialog-titlebar-close').on('click', () => this.hide());
75        this.$entry.keyup((e) => this.onEntry(e));
76        jQuery(this.result).on('click', 'a', (e) => this.onResultClick(e));
77    }
78
79    /**
80     * Handle all keyup events in the entry field
81     */
82    onEntry(e) {
83        if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { //left/right
84            return true; //ignore
85        }
86        if (e.key === 'Escape') { //Escape
87            this.hide();
88            e.preventDefault();
89            e.stopPropagation();
90            return false;
91        }
92        if (e.key === 'ArrowUp') { //Up
93            this.select(this.selected - 1);
94            e.preventDefault();
95            e.stopPropagation();
96            return false;
97        }
98        if (e.key === 'ArrowDown') { //Down
99            this.select(this.selected + 1);
100            e.preventDefault();
101            e.stopPropagation();
102            return false;
103        }
104        if (e.key === 'Enter') { //Enter
105            if (this.selected > -1) {
106                const $obj = this.$getResult(this.selected);
107                if ($obj.length > 0) {
108                    this.resultClick($obj.find('a')[0]);
109                }
110            } else if (this.$entry.val()) {
111                this.insertLink(this.$entry.val());
112            }
113
114            e.preventDefault();
115            e.stopPropagation();
116            return false;
117        }
118        this.autocomplete();
119    }
120
121    /**
122     * Get one of the results by index
123     *
124     * @param   num int result div to return
125     * @returns jQuery object
126     */
127    $getResult(num) {
128        return jQuery(this.result).find('div').eq(num);
129    }
130
131    /**
132     * Select the given result
133     */
134    select(num) {
135        if (num < 0) {
136            this.deselect();
137            return;
138        }
139
140        const $obj = this.$getResult(num);
141        if ($obj.length === 0) {
142            return;
143        }
144
145        this.deselect();
146        $obj.addClass('selected');
147
148        // make sure the item is viewable in the scroll view
149
150        //getting child position within the parent
151        const childPos = $obj.position().top;
152        //getting difference between the childs top and parents viewable area
153        const yDiff = childPos + $obj.outerHeight() - jQuery(this.result).innerHeight();
154
155        if (childPos < 0) {
156            //if childPos is above viewable area (that's why it goes negative)
157            jQuery(this.result)[0].scrollTop += childPos;
158        } else if (yDiff > 0) {
159            // if difference between childs top and parents viewable area is
160            // greater than the height of a childDiv
161            jQuery(this.result)[0].scrollTop += yDiff;
162        }
163
164        this.selected = num;
165    }
166
167    /**
168     * Deselect a result if any is selected
169     */
170    deselect() {
171        if (this.selected > -1) {
172            this.$getResult(this.selected).removeClass('selected');
173        }
174        this.selected = -1;
175    }
176
177    /**
178     * Handle clicks in the result set and dispatch them to
179     * resultClick()
180     */
181    onResultClick(e) {
182        if (!jQuery(e.target).is('a')) {
183            return;
184        }
185        e.stopPropagation();
186        e.preventDefault();
187        this.resultClick(e.target);
188        return false;
189    }
190
191    /**
192     * Handles the "click" on a given result anchor
193     */
194    resultClick(a) {
195        this.$entry.val(a.title);
196        if (a.title === '' || a.title.charAt(a.title.length - 1) === ':') {
197            this.autocomplete_exec();
198        } else {
199            if (jQuery(a.nextSibling).is('span')) {
200                this.insertLink(a.nextSibling.innerText);
201            } else {
202                this.insertLink('');
203            }
204        }
205    }
206
207    /**
208     * Insert the id currently in the entry box to the textarea,
209     * replacing the current selection or at the cursor position.
210     * When no selection is available the given title will be used
211     * as link title instead
212     *
213     * @param {string} title The heading text to use as link title if configured
214     */
215    insertLink(title) {
216        let link = this.$entry.val();
217        let selection;
218        let linkTitle;
219        if (!link) {
220            return;
221        }
222
223        // use the current selection, if not available use the one that was stored when the wizard was opened
224        selection = DWgetSelection(this.textArea);
225        if (selection.start === 0 && selection.end === 0) {
226            selection = this.selection;
227        }
228
229        // if the selection has any text, use it as the link title
230        linkTitle = selection.getText();
231        if (linkTitle.charAt(linkTitle.length - 1) === ' ') {
232            // don't include trailing space in selection
233            selection.end--;
234            linkTitle = selection.getText();
235        }
236
237        // if there is no selection, and useheading is enabled, use the heading text as the link title
238        if (!linkTitle && !DOKU_UHC) {
239            linkTitle = title;
240        }
241
242        // paste the link
243        const syntax = this.createLinkSyntax(link, linkTitle);
244        pasteText(selection, syntax.link, syntax);
245        this.hide();
246
247        // reset the entry to the parent namespace
248        const externallinkpattern = new RegExp('^((f|ht)tps?:)?//', 'i');
249        let entry_value;
250        if (externallinkpattern.test(this.$entry.val())) {
251            if (JSINFO.namespace) {
252                entry_value = JSINFO.namespace + ':';
253            } else {
254                entry_value = ''; //reset whole external links
255            }
256        } else {
257            entry_value = this.$entry.val().replace(/[^:]*$/, '')
258        }
259        this.$entry.val(entry_value);
260    }
261
262    /**
263     * Constructs the full syntax and calculates offsets
264     *
265     * @param {string} id
266     * @param {string} title
267     * @returns {{link: string, startofs: number, endofs: number }}
268     */
269    createLinkSyntax(id, title) {
270        // construct a relative link, except for external links
271        let link = id;
272        if (!id.match(/^(f|ht)tps?:\/\//i)) {
273            const refId = this.textArea.form.id.value;
274            link = LinkWizard.createRelativeID(refId, id);
275        }
276
277        let startofs = link.length;
278        let endofs = 0;
279
280        if (this.val.open) {
281            startofs += this.val.open.length;
282            link = this.val.open + link;
283        }
284        link += '|';
285        startofs += 1;
286        if (title) {
287            link += title;
288        }
289        if (this.val.close) {
290            link += this.val.close;
291            endofs = this.val.close.length;
292        }
293
294        return {link, startofs, endofs};
295    }
296
297    /**
298     * Start the page/namespace lookup timer
299     *
300     * Calls autocomplete_exec when the timer runs out
301     */
302    autocomplete() {
303        if (this.timer !== null) {
304            window.clearTimeout(this.timer);
305            this.timer = null;
306        }
307
308        this.timer = window.setTimeout(() => this.autocomplete_exec(), 350);
309    }
310
311    /**
312     * Executes the AJAX call for the page/namespace lookup
313     */
314    autocomplete_exec() {
315        const $res = jQuery(this.result);
316        this.deselect();
317        $res.html('<img src="' + DOKU_BASE + 'lib/images/throbber.gif" alt="" width="16" height="16" />')
318            .load(
319                DOKU_BASE + 'lib/exe/ajax.php',
320                {
321                    call: 'linkwiz',
322                    q: this.$entry.val()
323                }
324            );
325    }
326
327    /**
328     * Show the link wizard
329     */
330    show() {
331        this.selection = DWgetSelection(this.textArea);
332        this.$wiz.show();
333        this.$entry.focus();
334        this.autocomplete();
335
336        // Move the cursor to the end of the input
337        const temp = this.$entry.val();
338        this.$entry.val('');
339        this.$entry.val(temp);
340    }
341
342    /**
343     * Hide the link wizard
344     */
345    hide() {
346        this.$wiz.hide();
347        this.textArea.focus();
348    }
349
350    /**
351     * Toggle the link wizard
352     */
353    toggle() {
354        if (this.$wiz.css('display') === 'none') {
355            this.show();
356        } else {
357            this.hide();
358        }
359    }
360
361    /**
362     * Create a relative ID from a given reference ID and a full ID to link to
363     *
364     * Both IDs are expected to be clean, (eg. the result of cleanID()). No relative paths,
365     * leading colons or similar things are alowed. As long as pages have a common prefix,
366     * a relative link is constructed.
367     *
368     * This method is static and meant to be reused by other scripts if needed.
369     *
370     * @todo does currently not create page relative links using ~
371     * @param {string} ref The ID of a page the link is used on
372     * @param {string} id The ID to link to
373     */
374    static createRelativeID(ref, id) {
375        const sourceNs = ref.split(':');
376        [/*sourcePage*/] = sourceNs.pop();
377        const targetNs = id.split(':');
378        const targetPage = targetNs.pop();
379        const relativeID = [];
380
381        // Find the common prefix length
382        let commonPrefixLength = 0;
383        while (
384            commonPrefixLength < sourceNs.length &&
385            commonPrefixLength < targetNs.length &&
386            sourceNs[commonPrefixLength] === targetNs[commonPrefixLength]
387            ) {
388            commonPrefixLength++;
389        }
390
391
392        if (sourceNs.length) {
393            // special treatment is only needed when the reference is a namespaced page
394            if (commonPrefixLength) {
395                if (commonPrefixLength === sourceNs.length && commonPrefixLength === targetNs.length) {
396                    // both pages are in the same namespace
397                    // link consists of simple page only
398                    // namespaces are irrelevant
399                } else if (commonPrefixLength < sourceNs.length) {
400                    // add .. for each missing namespace from common to the target
401                    relativeID.push(...Array(sourceNs.length - commonPrefixLength).fill('..'));
402                } else {
403                    // target is below common prefix, add .
404                    relativeID.push('.');
405                }
406            } else if (targetNs.length === 0) {
407                // target is in the root namespace, but source is not, make it absolute
408                relativeID.push('');
409            }
410            // add any remaining parts of targetNS
411            relativeID.push(...targetNs.slice(commonPrefixLength));
412        } else {
413            // source is in the root namespace, just use target as is
414            relativeID.push(...targetNs);
415        }
416
417        // add targetPage
418        relativeID.push(targetPage);
419
420        return relativeID.join(':');
421    }
422
423}
424
425const dw_linkwiz = new LinkWizard();
426
427