xref: /plugin/catmenu/script.js (revision aa591c9040aa9d58df44eaf65df693766613dc9f)
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, '&lt;')
127        .replace(/>/g, '&gt;');
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