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