1/** 2 * The Tree Move Manager 3 * 4 * This script handles the move tree and all its interactions. 5 * 6 * The script supports combined and separate page/media trees. Items have their orignal ID in data-orig and their 7 * current ID in data-id. 8 * 9 * This is pure vanilla JavaScript without any dependencies to jQuery. It is lazy loaded by the main script. 10 */ 11class PluginMoveTree { 12 #ENDPOINT = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_move_tree'; 13 14 icons = { 15 'close': 'M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z', 16 'open': 'M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z', 17 'page': 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', 18 'media': 'M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z', 19 'rename': 'M18,4V3A1,1 0 0,0 17,2H5A1,1 0 0,0 4,3V7A1,1 0 0,0 5,8H17A1,1 0 0,0 18,7V6H19V10H9V21A1,1 0 0,0 10,22H12A1,1 0 0,0 13,21V12H21V4H18Z', 20 'drag': 'M4 4V22H20V24H4C2.9 24 2 23.1 2 22V4H4M15 7H20.5L15 1.5V7M8 0H16L22 6V18C22 19.11 21.11 20 20 20H8C6.89 20 6 19.1 6 18V2C6 .89 6.89 0 8 0M17 16V14H8V16H17M20 12V10H8V12H20Z', 21 }; 22 23 #mainElement; 24 #mediaTree; 25 #pageTree; 26 #dragTarget; 27 #dragIcon; 28 29 /** 30 * Initialize the base tree and attach all event handlers 31 * 32 * @param {HTMLElement} main 33 */ 34 constructor(main) { 35 this.#mainElement = main; 36 this.#mediaTree = this.#mainElement.querySelector('.move-media'); 37 this.#pageTree = this.#mainElement.querySelector('.move-pages'); 38 39 40 this.#dragIcon = this.icon('drag'); 41 this.#dragIcon.classList.add('drag-icon'); 42 this.#mainElement.appendChild(this.#dragIcon); 43 44 this.#mainElement.addEventListener('click', this.clickHandler.bind(this)); 45 this.#mainElement.addEventListener('dragstart', this.dragStartHandler.bind(this)); 46 this.#mainElement.addEventListener('dragover', this.dragOverHandler.bind(this)); 47 this.#mainElement.addEventListener('drop', this.dragDropHandler.bind(this)); 48 this.#mainElement.addEventListener('dragend', this.dragEndHandler.bind(this)); 49 this.#mainElement.querySelector('form').addEventListener('submit', this.submitHandler.bind(this)); 50 51 // load and open the initial tree 52 this.#init(); 53 54 // make tree visible 55 this.#mainElement.style.display = 'block'; 56 } 57 58 /** 59 * Initialize the tree 60 * 61 * @returns {Promise<void>} 62 */ 63 async #init() { 64 await Promise.all([ 65 this.loadSubTree('', 'pages'), 66 this.loadSubTree('', 'media'), 67 ]); 68 69 await this.openNamespace(JSINFO.namespace); 70 } 71 72 /** 73 * Handle all item clicks 74 * 75 * @param {MouseEvent} ev 76 */ 77 clickHandler(ev) { 78 const target = ev.target; 79 const li = target.closest('li'); 80 if (!li) return; 81 82 // we want to handle clicks on these elements only 83 const clicked = target.closest('i,button,span'); 84 if (!clicked) return; 85 86 // ignore clicks on the root element 87 if(li.classList.contains('tree-root')) return; 88 89 // icon click selects the item 90 if (clicked.tagName.toLowerCase() === 'i') { 91 ev.stopPropagation(); 92 li.classList.toggle('selected'); 93 return; 94 } 95 96 // button click opens rename dialog 97 if (clicked.tagName.toLowerCase() === 'button') { 98 ev.stopPropagation(); 99 this.renameGui(li); 100 return; 101 } 102 103 // click on name opens/closes namespace 104 if (clicked.tagName.toLowerCase() === 'span' && li.classList.contains('move-ns')) { 105 ev.stopPropagation(); 106 this.toggleNamespace(li); 107 } 108 } 109 110 /** 111 * Submit the data for the move operation 112 * 113 * @param {FormDataEvent} ev 114 */ 115 submitHandler(ev) { 116 // gather all changed items 117 const data = []; 118 this.#mainElement.querySelectorAll('.changed').forEach(li => { 119 let entry = { 120 src: li.dataset.orig, 121 dst: li.dataset.id, 122 type: this.isItemMedia(li) ? 'media' : 'page', 123 class: this.isItemNamespace(li) ? 'ns' : 'doc', 124 }; 125 data.push(entry); 126 127 // if this is a namspace that is shared between media and pages, add a second entry 128 if (entry.class === 'ns' && entry.type === 'media' && this.isItemPage(li)) { 129 entry = {...entry}; // clone 130 entry.type = 'page'; 131 data.push(entry); 132 } 133 }); 134 135 // add JSON data to form, then let the event continue 136 const input = document.createElement('input'); 137 input.type = 'hidden'; 138 input.name = 'json'; 139 input.value = JSON.stringify(data); 140 ev.target.appendChild(input); 141 } 142 143 /** 144 * Begin drag operation 145 * 146 * @param {DragEvent} ev 147 */ 148 dragStartHandler(ev) { 149 if (!ev.target) return; 150 const li = ev.target.closest('li'); 151 if (!li) return; 152 153 ev.dataTransfer.setData('text/plain', li.dataset.id); // FIXME needed? 154 ev.dataTransfer.effectAllowed = 'move'; 155 ev.dataTransfer.setDragImage(this.#dragIcon, -12, -12); 156 157 // the dragged element is always selected 158 li.classList.add('selected'); 159 } 160 161 /** 162 * Higlight drop zone and allow dropping 163 * 164 * @param {DragEvent} ev 165 */ 166 dragOverHandler(ev) { 167 // remove any previous drop zone 168 if (this.#dragTarget) { 169 this.#dragTarget.classList.remove('drop-zone'); 170 } 171 172 if (!ev.target) return; // the element the mouse is over 173 174 const li = ev.target.closest('li'); 175 if (!li) return; 176 177 let ul; // the UL we drop into 178 if (li.classList.contains('move-ns')) { 179 // drop on a namespace, use its UL 180 ul = li.querySelector('ul'); 181 } else { 182 // drop on a file or page, use parent UL 183 ul = ev.target.closest('ul'); 184 } 185 if (!ul) return; 186 if(ul.classList.contains('open') === false) return; // only drop into open namespaces 187 ev.preventDefault(); // allow drop 188 189 this.#dragTarget = ul; 190 this.#dragTarget.classList.add('drop-zone'); 191 } 192 193 /** 194 * Handle the Drop operation 195 * 196 * @param {DragEvent} ev 197 */ 198 dragDropHandler(ev) { 199 if (!ev.target) return; 200 201 const dst = this.#dragTarget; // the UL we drop into 202 203 // move all selected items to the drop target 204 const elements = this.#mainElement.querySelectorAll('.selected'); 205 elements.forEach(src => { 206 const newID = this.getNewId(src.dataset.id, dst.dataset.id); 207 console.log('move started', src.dataset.id + ' → ' + newID); 208 209 // ensure that item stays in its own tree, ignore cross-tree moves 210 if (this.itemTree(src).contains(dst) === false) { 211 return; 212 } 213 214 // same ID? we consider this an abort 215 if (newID === src.dataset.id) { 216 src.classList.remove('selected'); 217 return; 218 } 219 220 // check if item with same ID and type already exists 221 let dupSelector = `li[data-id="${newID}"]`; 222 if (this.isItemMedia(src)) { 223 dupSelector += '.move-media'; 224 } else { 225 dupSelector += '.move-pages'; 226 } 227 if (this.isItemNamespace(src)) { 228 dupSelector += '.move-ns'; 229 } else { 230 dupSelector += ':not(.move-ns)'; 231 } 232 if (this.itemTree(src).querySelector(dupSelector)) { 233 alert(LANG.plugins.move.duplicate.replace('%s', newID)); 234 src.classList.remove('selected'); 235 return; 236 } 237 238 try { 239 dst.append(src); 240 } catch (e) { 241 console.log('move aborted', e.message); // moved into itself 242 src.classList.remove('selected'); 243 return; 244 } 245 this.updateMovedItem(src, newID); 246 }); 247 this.updatePassiveSubNamespaces(dst); 248 this.sortList(dst); 249 } 250 251 /** 252 * Clean up after drag'n'drop operation 253 * 254 * @param {DragEvent} ev 255 */ 256 dragEndHandler(ev) { 257 if (this.#dragTarget) { 258 this.#dragTarget.classList.remove('drop-zone'); 259 } 260 } 261 262 /** 263 * Open the given namespace and all its parents 264 * 265 * @param {string} namespace 266 * @returns {Promise<void>} 267 */ 268 async openNamespace(namespace) { 269 const namespaces = namespace.split(':'); 270 271 for (let i = 0; i < namespaces.length; i++) { 272 const ns = namespaces.slice(0, i + 1).join(':'); 273 const li = this.#mainElement.querySelectorAll(`li[data-orig="${ns}"].move-ns`); 274 if (!li.length) return; 275 276 // we might have multiple namespaces with the same ID (media and pages) 277 // we open both in parallel and wait for them 278 const promises = []; 279 for (const el of li) { 280 const ul = el.querySelector('ul'); 281 if (!ul) { 282 promises.push(this.toggleNamespace(el)); 283 } 284 } 285 await Promise.all(promises); 286 } 287 } 288 289 /** 290 * Rename an item via a prompt dialog 291 * 292 * @param li 293 */ 294 renameGui(li) { 295 const basename = this.getBase(li.dataset.id); 296 const newname = window.prompt(LANG.plugins.move.renameitem, basename); 297 const clean = this.cleanID(newname); 298 299 if (!clean || clean === basename || newname === basename ) { 300 return; 301 } 302 303 // avoid extension changes for media items 304 if (!this.isItemNamespace(li) && this.isItemMedia(li)) { 305 if (this.getExtension(li.dataset.id) !== this.getExtension(clean)) { 306 alert(LANG.plugins.move.extchange); 307 return; 308 } 309 } 310 311 // construct new ID and check for duplicate 312 const ns = this.getNamespace(li.dataset.id); 313 const newID = ns ? ns + ':' + clean : clean; 314 if (this.itemTree(li).querySelector(`li[data-id="${newID}"]`)) { 315 alert(LANG.plugins.move.duplicate.replace('%s', newID)); 316 return; 317 } 318 319 // update the item 320 this.updateMovedItem(li, newID); 321 322 // if this was a namespace, update sub namespaces 323 if (this.isItemNamespace(li)) { 324 this.updatePassiveSubNamespaces(li.querySelector('ul')); 325 } 326 } 327 328 329 /** 330 * Open or close a namespace 331 * 332 * @param li 333 * @returns {Promise<void>} 334 */ 335 async toggleNamespace(li) { 336 const isOpen = li.classList.toggle('open'); 337 338 // swap icon 339 const icon = li.querySelector('i'); 340 icon.parentNode.insertBefore(this.icon(isOpen ? 'open' : 'close'), icon); 341 icon.remove(); 342 343 if (isOpen) { 344 // check if UL already exists and reuse it 345 let ul = li.querySelector('ul'); 346 if (ul) { 347 ul.style.display = ''; 348 return; 349 } 350 351 // create new UL 352 ul = document.createElement('ul'); 353 ul.classList = li.classList; 354 ul.dataset.id = li.dataset.id; 355 ul.dataset.orig = li.dataset.orig; 356 li.appendChild(ul); 357 358 const promises = []; 359 360 if (li.classList.contains('move-pages')) { 361 promises.push(this.loadSubTree(li.dataset.orig, 'pages')); 362 } 363 if (li.classList.contains('move-media')) { 364 promises.push(this.loadSubTree(li.dataset.orig, 'media')); 365 } 366 await Promise.all(promises); 367 } else { 368 const ul = li.querySelector('ul'); 369 if (ul) { 370 ul.style.display = 'none'; 371 } 372 } 373 } 374 375 /** 376 * Load the data for a namespace 377 * 378 * @param {string} namespace 379 * @param {string} type 380 * @returns {Promise<void>} 381 */ 382 async loadSubTree(namespace, type) { 383 384 const data = new FormData; 385 data.append('ns', namespace); 386 data.append('is_media', type === 'media' ? 1 : 0); 387 388 const response = await fetch(this.#ENDPOINT, { 389 method: 'POST', 390 body: data 391 }); 392 const result = await response.json(); 393 394 this.renderSubTree(namespace, result, type); 395 } 396 397 /** 398 * Render the data for a namespace 399 * 400 * @param {string} namespace 401 * @param {object[]} data 402 * @param {string} type 403 */ 404 renderSubTree(namespace, data, type) { 405 const selector = `ul[data-orig="${namespace}"].move-${type}.move-ns`; 406 const parent = this.#mainElement.querySelector(selector); 407 408 for (const item of data) { 409 let li; 410 // reuse namespace 411 if (item.type === 'd') { 412 li = parent.querySelector(`li[data-orig="${item.id}"].move-ns`); 413 } 414 // create new item 415 if (!li) { 416 li = this.createListItem(item, type); 417 parent.appendChild(li); 418 } 419 // ensure class is added to reused namespaces 420 li.classList.add(`move-${type}`); 421 } 422 423 this.sortList(parent); 424 this.updatePassiveSubNamespaces(parent); // subtree might have been loaded into a renamed namespace 425 } 426 427 /** 428 * Sort the children of the given element 429 * 430 * namespaces are sorted first, then by ID 431 * 432 * @param {HTMLUListElement} parent 433 */ 434 sortList(parent) { 435 [...parent.children] 436 .sort((a, b) => { 437 // sort namespaces first 438 if (a.classList.contains('move-ns') && !b.classList.contains('move-ns')) { 439 return -1; 440 } 441 if (!a.classList.contains('move-ns') && b.classList.contains('move-ns')) { 442 return 1; 443 } 444 // sort by ID 445 return a.dataset.id.localeCompare(b.dataset.id); 446 }) 447 .forEach(node => parent.appendChild(node)); 448 } 449 450 /** 451 * Update the IDs of all sub-namespaces without marking them as moved 452 * 453 * The update is not marked as a change, because it will be covered in the move of an upper namespace. 454 * But updating the ID ensures that all drags that go into this namespace will already reflect the new namespace. 455 * 456 * @param {HTMLUListElement} parent 457 */ 458 updatePassiveSubNamespaces(parent) { 459 const ns = parent.dataset.id; // parent is the namespace 460 461 for (const li of parent.children) { 462 if (!this.isItemNamespace(li)) continue; 463 464 const newID = this.getNewId(li.dataset.id, ns); 465 li.dataset.id = newID; 466 467 const sub = li.getElementsByTagName('ul'); 468 if (sub.length) { 469 sub[0].dataset.id = newID; 470 this.updatePassiveSubNamespaces(sub[0]); 471 } 472 } 473 } 474 475 /** 476 * Get the new ID when moving an item to a new namespace 477 * 478 * @param oldId 479 * @param newNS 480 * @returns {string} 481 */ 482 getNewId(oldId, newNS) { 483 const base = this.getBase(oldId); 484 return newNS ? newNS + ':' + base : base; 485 } 486 487 /** 488 * Adjust the ID of a moved item 489 * 490 * @param {HTMLLIElement} li The item to rename 491 * @param {string} newID The new ID 492 */ 493 updateMovedItem(li, newID) { 494 const name = li.querySelector('span'); 495 496 if (li.dataset.orig === newID) { 497 // item was moved back to its original ID 498 li.classList.remove('changed'); 499 name.title = ''; 500 } else if (li.dataset.id !== newID) { 501 li.dataset.id = newID; 502 li.classList.add('changed'); 503 name.textContent = this.getBase(newID); 504 name.title = li.dataset.orig + ' → ' + newID; 505 506 const ul = li.querySelector('ul'); 507 if (ul) { 508 ul.dataset.id = newID; 509 } 510 } else { 511 li.classList.remove('changed'); 512 name.title = ''; 513 } 514 } 515 516 /** 517 * Check if an item is a namespace item 518 * 519 * @param {HTMLLIElement} li 520 * @returns {boolean} 521 */ 522 isItemNamespace(li) { 523 return li.classList.contains('move-ns'); 524 } 525 526 /** 527 * Check if an item is a media item 528 * 529 * @param {HTMLLIElement} li 530 * @returns {boolean} 531 */ 532 isItemMedia(li) { 533 return li.classList.contains('move-media'); 534 } 535 536 /** 537 * Check if an item is a page item 538 * 539 * @param {HTMLLIElement} li 540 * @returns {boolean} 541 */ 542 isItemPage(li) { 543 return li.classList.contains('move-pages'); 544 } 545 546 /** 547 * Get the tree for the given item 548 * 549 * @param li 550 * @returns {HTMLUListElement} 551 */ 552 itemTree(li) { 553 if (this.isItemMedia(li)) { 554 return this.#mediaTree; 555 } else { 556 return this.#pageTree; 557 } 558 } 559 560 /** 561 * Create a list item 562 * 563 * @param {object} item 564 * @param {string} type 565 * @returns {HTMLLIElement} 566 */ 567 createListItem(item, type) { 568 const li = document.createElement('li'); 569 li.dataset.id = item.id; 570 li.dataset.orig = item.id; // track the original ID 571 li.classList.add(`move-${type}`); 572 li.draggable = true; 573 574 const wrapper = document.createElement('div'); 575 wrapper.classList.add('li'); 576 li.appendChild(wrapper); 577 578 let icon; 579 if (item.type === 'd') { 580 li.classList.add('move-ns'); 581 icon = this.icon('close'); 582 } else if (type === 'media') { 583 icon = this.icon('media'); 584 } else { 585 icon = this.icon('page'); 586 } 587 icon.title = LANG.plugins.move.select; 588 wrapper.appendChild(icon); 589 590 const name = document.createElement('span'); 591 name.textContent = this.getBase(item.id); 592 wrapper.appendChild(name); 593 594 const renameBtn = document.createElement('button'); 595 this.icon('rename', renameBtn); 596 renameBtn.title = LANG.plugins.move.renameitem; 597 wrapper.appendChild(renameBtn); 598 599 return li; 600 } 601 602 /** 603 * Create an icon element 604 * 605 * @param {string} type 606 * @param {HTMLElement} element The element to insert the SVG into, a new <i> if not given 607 * @returns {HTMLElement} 608 */ 609 icon(type, element = null) { 610 if (!element) { 611 element = document.createElement('i'); 612 } 613 614 element.classList.add('icon'); 615 element.innerHTML = `<svg viewBox="0 0 24 24"><path d="${this.icons[type]}" /></svg>`; 616 return element; 617 } 618 619 /** 620 * Get the base part (filename) of an ID 621 * 622 * @param {string} id 623 * @returns {string} 624 */ 625 getBase(id) { 626 return id.split(':').slice(-1)[0]; 627 } 628 629 /** 630 * Get the extension part of an ID 631 * 632 * This isn't perfect, but adds some safety 633 * 634 * @param {string} id 635 * @returns {string} 636 */ 637 getExtension(id) { 638 const parts = id.split('.'); 639 return parts.length > 1 ? parts.pop() : ''; 640 } 641 642 /** 643 * Get the namespace part of an ID 644 * 645 * @param {string} id 646 * @returns {string} 647 */ 648 getNamespace(id) { 649 if (id.includes(':') === false) { 650 return ''; 651 } 652 return id.split(':').slice(0, -1).join(':'); 653 } 654 655 /** 656 * Very simplistic cleanID() in JavaScript 657 * 658 * Strips out namespaces 659 * 660 * @param {string} id 661 */ 662 cleanID(id) { 663 if (!id) return ''; 664 665 id = id.replace(/[!"#$%§&'()+,\/;<=>?@\[\]^`{|}~\\:*\s]+/g, '_'); 666 id = id.replace(/^_+/, ''); 667 id = id.replace(/_+$/, ''); 668 id = id.toLowerCase(); 669 670 return id; 671 }; 672} 673