1(function loadCatmenuProsemirrorAddon() { 2 if (window.__catmenuPmAddonRequested) return; 3 window.__catmenuPmAddonRequested = true; 4 5 var base = (typeof DOKU_BASE !== 'undefined' && DOKU_BASE) ? DOKU_BASE : '/'; 6 var src = base + 'lib/plugins/catmenu/script/prosemirror.js'; 7 var script = document.createElement('script'); 8 script.src = src; 9 script.defer = true; 10 document.head.appendChild(script); 11})(); 12 13/** 14 * Hauteur minimale (px) pour le sous-menu ouvert. 15 * Évite un sous-menu trop petit sur les pages courtes. 16 */ 17const CATMENU_MIN_SUBMENU_HEIGHT = 500; 18 19/** 20 * Ajuste la hauteur maximale du sous-menu ouvert en fonction de l'espace disponible. 21 * Retourne la hauteur calculée. 22 */ 23function catmenu_adjustSubmenuHeight(submenu, menuContainer) { 24 var allTitles = menuContainer.querySelectorAll('.menu-item .header:not(.menu-item .menu-item .header)'); 25 26 // Hauteur totale du conteneur 27 var containerHeight = menuContainer.clientHeight; 28 29 // Somme des hauteurs des titres de premier niveau (bordures + padding + margin) 30 var titlesHeight = Array.from(allTitles).reduce((sum, title) => { 31 var computedStyle = window.getComputedStyle(title.parentElement); 32 var marginTop = parseInt(computedStyle.marginTop) || 0; 33 return sum + title.offsetHeight + marginTop; 34 }, 0); 35 36 // Hauteur disponible, avec plancher minimal 37 var availableHeight = Math.max( 38 Math.max(containerHeight, window.innerHeight) - titlesHeight - menuContainer.getBoundingClientRect().top, 39 CATMENU_MIN_SUBMENU_HEIGHT 40 ); 41 42 submenu.style.maxHeight = availableHeight + 'px'; 43 return availableHeight; 44} 45 46/** 47 * Copie un texte dans le presse-papiers. 48 * Utilise l'API Clipboard moderne (HTTPS), avec fallback execCommand pour 49 * les contextes non-sécurisés ou les navigateurs anciens. 50 */ 51function copyToClipboard(text) { 52 if (!text) return; 53 54 if (navigator.clipboard && window.isSecureContext) { 55 // Méthode moderne (nécessite HTTPS) 56 navigator.clipboard.writeText(text) 57 .then(() => { 58 catmenu_showNotification('URL copiée dans le presse-papiers !'); 59 }) 60 .catch(err => { 61 console.error('Catmenu — erreur lors de la copie :', err); 62 }); 63 } else { 64 // Fallback pour contextes non-sécurisés / anciens navigateurs 65 const textarea = document.createElement('textarea'); 66 textarea.value = text; 67 textarea.style.position = 'fixed'; 68 textarea.style.opacity = '0'; 69 document.body.appendChild(textarea); 70 textarea.focus(); 71 textarea.select(); 72 try { 73 document.execCommand('copy'); 74 catmenu_showNotification('URL copiée dans le presse-papiers !'); 75 } catch (err) { 76 console.error('Catmenu — erreur lors de la copie (fallback) :', err); 77 } 78 document.body.removeChild(textarea); 79 } 80} 81 82/** 83 * Affiche une notification discrète (toast) pendant 2 secondes. 84 * Préfère la notification DokuWiki si disponible, sinon affiche un toast minimal. 85 */ 86function catmenu_showNotification(message) { 87 if (typeof dw_alert === 'function') { 88 dw_alert(message); 89 return; 90 } 91 92 // Toast léger autonome 93 let toast = document.getElementById('catmenu_toast'); 94 if (!toast) { 95 toast = document.createElement('div'); 96 toast.id = 'catmenu_toast'; 97 Object.assign(toast.style, { 98 position: 'fixed', 99 bottom: '20px', 100 right: '20px', 101 background: '#333', 102 color: '#fff', 103 padding: '8px 14px', 104 borderRadius: '4px', 105 zIndex: '99999', 106 fontSize: '13px', 107 opacity: '0', 108 transition: 'opacity 0.3s', 109 pointerEvents: 'none', 110 }); 111 document.body.appendChild(toast); 112 } 113 toast.textContent = message; 114 toast.style.opacity = '1'; 115 clearTimeout(toast._hideTimer); 116 toast._hideTimer = setTimeout(() => { toast.style.opacity = '0'; }, 2000); 117} 118 119/** 120 * Échappe les caractères spéciaux pour une utilisation sûre dans un attribut HTML. 121 */ 122function catmenu_escapeHtml(str) { 123 return String(str || '') 124 .replace(/&/g, '&') 125 .replace(/"/g, '"') 126 .replace(/</g, '<') 127 .replace(/>/g, '>'); 128} 129 130function catmenu_escapeRegExp(text) { 131 return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 132} 133 134/** 135 * Normalise un titre en identifiant de page DokuWiki : 136 * suppression des diacritiques, mise en minuscules, remplacement des 137 * caractères non-alphanumériques par le séparateur configuré. 138 */ 139function catmenu_normalizePageId(text, conf) { 140 const sep = (conf && conf.sepchar) ? conf.sepchar : '_'; 141 const sepPattern = new RegExp(catmenu_escapeRegExp(sep) + '+', 'g'); 142 const trimPattern = new RegExp('^' + catmenu_escapeRegExp(sep) + '+|' + catmenu_escapeRegExp(sep) + '+$', 'g'); 143 144 return String(text || '') 145 .normalize('NFD') 146 .replace(/[\u0300-\u036f]/g, '') 147 .toLowerCase() 148 .replace(/[^a-z0-9]+/g, sep) 149 .replace(sepPattern, sep) 150 .replace(trimPattern, ''); 151} 152 153/** 154 * Fallback de création de page par saisie directe (invite navigateur), 155 * utilisé si le plugin newpagefill n'est pas disponible. 156 */ 157function catmenu_promptNewPageFallback(namespace, conf, urlSeparator) { 158 const raw = window.prompt('Identifiant de la page à créer ?'); 159 if (!raw) return; 160 161 const pageTitle = String(raw).trim(); 162 if (!pageTitle) return; 163 const pageId = catmenu_normalizePageId(pageTitle, conf); 164 if (!pageId) return; 165 166 let targetUrl; 167 if (Number(conf.userewrite) === 1) { 168 const root = (typeof DOKU_BASE !== 'undefined' && DOKU_BASE ? DOKU_BASE : '/').replace(/\/$/, ''); 169 const cleanedNamespace = String(namespace || '').trim().replace(/^:+|:+$/g, ''); 170 const baseHref = cleanedNamespace 171 ? root + '/' + cleanedNamespace.split(':').map(encodeURIComponent).join('/') 172 : root; 173 targetUrl = baseHref + urlSeparator + encodeURIComponent(pageId); 174 } else { 175 const root = typeof DOKU_BASE !== 'undefined' && DOKU_BASE ? DOKU_BASE : '/'; 176 const cleanedNamespace = String(namespace || '').trim().replace(/^:+|:+$/g, ''); 177 const parts = []; 178 if (cleanedNamespace) parts.push(cleanedNamespace); 179 parts.push(pageId); 180 targetUrl = root; 181 targetUrl += (targetUrl.indexOf('?') >= 0 ? '&' : '?') + 'id=' + encodeURIComponent(parts.join(':')); 182 } 183 targetUrl += (targetUrl.indexOf('?') >= 0 ? '&' : '?') + 'do=edit'; 184 window.location.href = targetUrl; 185} 186 187/** 188 * Ouvre ou ferme le sous-menu d'un item, en fermant les autres items de premier niveau. 189 */ 190function catmenu_toggleSectionMenu(menuItem, menuContainer) { 191 let isOpen = !menuItem.classList.contains('open'); 192 193 let topMenuItems = Array.from(menuContainer.querySelectorAll('.menu-item:not(.menu-item .menu-item)')); 194 195 let currentTopMenu = topMenuItems.find(item => item.contains(menuItem)); 196 let othersTopMenu = topMenuItems.filter(item => item !== currentTopMenu); 197 198 // Fermer tous les autres items de premier niveau 199 othersTopMenu.forEach(item => { 200 item.classList.remove('open'); 201 let submenu = item.getElementsByClassName('submenu')[0]; 202 if (submenu) { 203 submenu.style.maxHeight = null; 204 } 205 }); 206 207 let currentTopSubmenu = currentTopMenu.getElementsByClassName('submenu')[0]; 208 if (isOpen) { 209 let newHeight = catmenu_adjustSubmenuHeight(currentTopSubmenu, menuContainer); 210 211 // Défilement automatique vers l'item actif si le contenu déborde 212 setTimeout(() => { 213 let hasOverflow = currentTopSubmenu.scrollHeight > newHeight; 214 if (hasOverflow) { 215 currentTopSubmenu.scrollTo({ 216 top: menuItem.offsetTop - currentTopSubmenu.offsetTop - 15, 217 }); 218 } 219 }, 0); 220 } else { 221 currentTopSubmenu.style.maxHeight = null; 222 } 223 menuItem.classList.toggle('open'); 224} 225 226/** 227 * Construit récursivement le menu à partir des données JSON fournies par le serveur. 228 * 229 * @param {Object} conf Configuration DokuWiki (userewrite, start, sepchar) 230 * @param {Array} menuData Tableau d'items de menu (titre, url, icône, sous-arbre…) 231 * @param {HTMLElement} parentElement Conteneur dans lequel insérer les items 232 * @param {HTMLElement} [menuContainer] Conteneur racine (passé en récursion) 233 */ 234function catmenu_generateSectionMenu(conf, menuData, parentElement, menuContainer = null) { 235 const URL_SEPARATOR = conf.userewrite == 1 ? '/' : ':'; 236 237 const AUTH_READ = 1; 238 const AUTH_EDIT = 2; 239 const AUTH_CREATE = 4; 240 const AUTH_UPLOAD = 8; 241 const AUTH_DELETE = 16; 242 243 menuData.forEach(item => { 244 let menuItem = document.createElement('div'); 245 menuItem.classList.add('menu-item'); 246 247 let header = document.createElement('div'); 248 header.classList.add('header'); 249 header.dataset.folderNamespace = item.folderNamespace || ''; 250 header.dataset.permission = item.permission || 0; 251 header.dataset.pagesiconUploadUrl = item.pagesiconUploadUrl || ''; 252 header.dataset.href = item.url || ''; 253 254 if (item.icon) { 255 let icon = document.createElement('img'); 256 icon.classList.add('icon'); 257 icon.loading = 'lazy'; 258 icon.src = item.icon; 259 header.appendChild(icon); 260 } 261 262 let title; 263 if (item.url) { 264 title = document.createElement('a'); 265 title.href = item.url; 266 } else { 267 title = document.createElement('span'); 268 } 269 title.textContent = item.title; 270 header.title = item.title; 271 header.appendChild(title); 272 273 // Mise en évidence de la page courante 274 let isCurrent = (':' + JSINFO.id + ':').indexOf(':' + item.namespace + ':') >= 0; 275 if (isCurrent) { 276 header.classList.add('current'); 277 menuItem.classList.add('open'); 278 } 279 menuItem.appendChild(header); 280 parentElement.appendChild(menuItem); 281 282 if (item.subtree && item.subtree.length > 0) { 283 header.classList.add('arrow'); 284 285 let submenu = document.createElement('div'); 286 submenu.classList.add('submenu'); 287 288 catmenu_generateSectionMenu(conf, item.subtree, submenu, menuContainer ?? parentElement); 289 menuItem.appendChild(submenu); 290 291 header.addEventListener('click', (event) => { 292 const isLinkClick = !!event.target.closest('a'); 293 if (isLinkClick) { 294 // Un clic sur le lien ne doit pas fermer la section déjà ouverte 295 if (!menuItem.classList.contains('open')) { 296 catmenu_toggleSectionMenu(menuItem, menuContainer ?? parentElement); 297 } 298 return; 299 } 300 catmenu_toggleSectionMenu(menuItem, menuContainer ?? parentElement); 301 }); 302 } 303 }); 304 305 // Initialisations au niveau racine uniquement (évite les doublons en récursion) 306 if (!menuContainer) { 307 // Recalcul de la hauteur lors d'un redimensionnement de la fenêtre 308 window.addEventListener('resize', () => { 309 let openedTopSubmenu = parentElement.querySelector('.menu-item:not(.menu-item .menu-item).open > .submenu'); 310 if (openedTopSubmenu) { 311 catmenu_adjustSubmenuHeight(openedTopSubmenu, parentElement); 312 } 313 }, false); 314 315 // ── Menu contextuel (clic droit) ────────────────────────────────────── 316 // Un seul élément contextMenu est créé dans le DOM et réutilisé. 317 let contextMenu = document.getElementById('catmenu_contextMenu'); 318 if (!contextMenu) { 319 contextMenu = document.createElement('div'); 320 contextMenu.id = 'catmenu_contextMenu'; 321 document.body.appendChild(contextMenu); 322 323 // Fermeture du menu contextuel sur clic ailleurs 324 document.addEventListener('click', function () { 325 contextMenu.style.display = 'none'; 326 }); 327 328 // Délégation d'événements pour toutes les actions du menu contextuel 329 contextMenu.addEventListener('click', function (event) { 330 const action = event.target.closest('[data-action]'); 331 if (!action) return; 332 333 const actionType = action.dataset.action; 334 335 if (actionType === 'newPage') { 336 event.preventDefault(); 337 event.stopPropagation(); 338 contextMenu.style.display = 'none'; 339 const ns = contextMenu.dataset.newPageNamespace || ''; 340 if (!window.NewPageFill || typeof window.NewPageFill.openCreatePageDialog !== 'function') { 341 catmenu_promptNewPageFallback(ns, conf, URL_SEPARATOR); 342 return; 343 } 344 window.NewPageFill.openCreatePageDialog({ 345 namespace: ns, 346 sepchar: conf.sepchar, 347 initialTitle: '', 348 }); 349 } 350 351 if (actionType === 'copy') { 352 event.preventDefault(); 353 event.stopPropagation(); 354 contextMenu.style.display = 'none'; 355 copyToClipboard(action.dataset.href || ''); 356 } 357 358 if (actionType === 'medias') { 359 event.preventDefault(); 360 event.stopPropagation(); 361 contextMenu.style.display = 'none'; 362 const ns = action.dataset.ns || ''; 363 const url = '/lib/exe/mediamanager.php?ns=' + encodeURIComponent(ns); 364 window.open(url, 'CatmenuMediasPopup', 'width=800,height=600,resizable=yes,scrollbars=yes'); 365 } 366 }); 367 } 368 369 // Actions activées dans la configuration du plugin (depuis JSINFO) 370 const enabledItems = new Set( 371 (JSINFO?.plugins?.catmenu?.context_menu_items) || ['newpage', 'reload', 'medias', 'pagesicon', 'url'] 372 ); 373 // Disponibilité du plugin pagesicon côté serveur 374 const pagesiconAvailable = !!(JSINFO?.plugins?.catmenu?.pagesicon_available); 375 376 // Ouverture du menu contextuel sur clic droit dans le catmenu 377 document.addEventListener('contextmenu', function (event) { 378 let header = event.target.closest('.header'); 379 if (!header || !header.closest('.catmenu')) { 380 contextMenu.style.display = 'none'; 381 return; 382 } 383 // Ignorer si l'en-tête appartient à un autre catmenu sur la page 384 if (!parentElement.contains(header)) { 385 return; 386 } 387 event.preventDefault(); 388 389 let permission = Number(header.dataset.permission) || 0; 390 let href = header.dataset.href || ''; 391 let folderNs = header.dataset.folderNamespace || ''; 392 393 // Titre de la section (texte brut, sans HTML) 394 let titleText = header.querySelector('a, span')?.textContent || header.title || ''; 395 396 // Construction du menu par manipulation DOM (pas de innerHTML + données) 397 contextMenu.innerHTML = ''; 398 399 let titleEl = document.createElement('p'); 400 let titleBold = document.createElement('b'); 401 titleBold.textContent = titleText; 402 titleEl.appendChild(titleBold); 403 contextMenu.appendChild(titleEl); 404 405 let hasActions = false; 406 407 // Créer une nouvelle page 408 if (enabledItems.has('newpage') && permission >= AUTH_CREATE) { 409 contextMenu.dataset.newPageNamespace = folderNs; 410 let btn = document.createElement('div'); 411 btn.className = 'button'; 412 btn.dataset.action = 'newPage'; 413 btn.textContent = ' Créer une nouvelle page'; 414 contextMenu.appendChild(btn); 415 hasActions = true; 416 } 417 418 // Recharger le cache 419 if (enabledItems.has('reload') && href && permission >= AUTH_EDIT) { 420 let btn = document.createElement('a'); 421 btn.className = 'button'; 422 btn.dataset.action = 'reload'; 423 btn.href = href + (href.indexOf('?') >= 0 ? '&' : '?') + 'purge=true'; 424 btn.textContent = ' Recharger le cache'; 425 contextMenu.appendChild(btn); 426 hasActions = true; 427 } 428 429 // Gérer les médias 430 if (enabledItems.has('medias') && permission >= AUTH_UPLOAD) { 431 let btnMedia = document.createElement('div'); 432 btnMedia.className = 'button'; 433 btnMedia.dataset.action = 'medias'; 434 btnMedia.dataset.ns = folderNs; 435 btnMedia.textContent = '️ Gérer les médias'; 436 contextMenu.appendChild(btnMedia); 437 hasActions = true; 438 } 439 440 // Gérer l'icône — uniquement si l'option est activée ET pagesicon est installé 441 if (enabledItems.has('pagesicon') && pagesiconAvailable && permission >= AUTH_UPLOAD) { 442 let iconUrl = header.dataset.pagesiconUploadUrl || ''; 443 if (iconUrl) { 444 let btnIcon = document.createElement('a'); 445 btnIcon.className = 'button'; 446 btnIcon.dataset.action = 'icon'; 447 btnIcon.href = iconUrl; 448 btnIcon.target = '_blank'; 449 btnIcon.textContent = '️ Gérer l\'icône'; 450 contextMenu.appendChild(btnIcon); 451 hasActions = true; 452 } 453 } 454 455 // Copier l'URL 456 if (enabledItems.has('url')) { 457 let btnCopy = document.createElement('div'); 458 btnCopy.className = 'button'; 459 btnCopy.dataset.action = 'copy'; 460 btnCopy.dataset.href = href; 461 btnCopy.textContent = ' Copier l\'URL'; 462 contextMenu.appendChild(btnCopy); 463 hasActions = true; 464 } 465 466 // N'afficher le menu que s'il contient au moins une action 467 if (!hasActions) return; 468 469 // Positionnement et affichage 470 contextMenu.style.left = event.clientX + 'px'; 471 contextMenu.style.top = event.clientY + 'px'; 472 contextMenu.style.display = 'block'; 473 474 contextMenu.dataset.namespace = header.dataset.namespace || ''; 475 contextMenu.dataset.folderNamespace = folderNs; 476 }, false); 477 } 478} 479 480// Exposition globale — le rendu inline du PHP appelle cette fonction via window. 481if (typeof window !== 'undefined') { 482 window.catmenu_generateSectionMenu = catmenu_generateSectionMenu; 483 if (typeof document !== 'undefined') { 484 document.dispatchEvent(new Event('catmenu:ready')); 485 } 486} 487