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  */
9 class 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 
425 window.dw_linkwiz = new LinkWizard();
426 
427