/* ═════════════════════════════════════════════════════════════════════ fdCtx — store Alpine UNIQUE du menu contextuel partagé. Enregistré dans 'alpine:init' avec garde d'exécution : que la page inclue ce partial dans base.html ou directement, le js ne s'exécute qu'une seule fois. Chaque page, à l'ouverture du menu, positionne : fdCtx.openMenu(evt, node, pageHash, handlers) ─────────────────────────────────────────────────────────────────── */ (function () { if (window.__fdCtxMenuRegistered) return; window.__fdCtxMenuRegistered = true; // Sur un chargement complet de page, ce script inline s'exécute AVANT // alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'. // Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et // 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite, // sinon le menu contextuel reste mort jusqu'au prochain chargement complet. function registerFdCtx() { if (!window.Alpine) return; if (window.Alpine.__fdCtxStore) return; window.Alpine.__fdCtxStore = true; Alpine.store('fdCtx', { open: false, x: 0, y: 0, node: null, page: null, maxH: 0, _cx: 0, _cy: 0, _ro: null, handlers: {}, /* Tags (workspace) */ availTags: [], tagAdding: false, tagExistingOpen: false, newTagColor: '#787774', tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'], /* Icon picker */ iconOpen: false, iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'], /* Positionne le menu à l'écran et expose les handlers de la page. ⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen d'état x-show. Une collision ici rendrait le menu PERMANENT. */ openMenu(ev, node, pageHash, handlers) { handlers = handlers || {}; this.node = node; this.page = pageHash; this.handlers = handlers; this.tagAdding = false; this.tagExistingOpen = false; this.iconOpen = false; this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774'; var cx = (ev && ev.clientX) || 0; var cy = (ev && ev.clientY) || 0; this._cx = cx; this._cy = cy; this.x = Math.max(8, Math.min(cx, window.innerWidth - 240)); this.y = Math.max(8, Math.min(cy, window.innerHeight - 48)); this.open = true; var self = this; /* Mesure la taille réelle du menu (une fois rendu) puis le replace pour qu'il reste TOUJOURS entièrement visible dans le viewport. Un ResizeObserver relance le placement à chaque fois que le menu change de taille (ouverture d'un sous-menu « tag », icônes, …), sinon il grandissait vers le bas et sortait de l'écran. */ var after = function () { self._place(); var el = document.querySelector('.fd-ctx-menu'); if (el && window.ResizeObserver) { if (self._ro) { try { self._ro.disconnect(); } catch { /* volontaire */ } } self._ro = new ResizeObserver(function () { self._place(); }); self._ro.observe(el); } }; if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after); else setTimeout(after, 0); }, /* Reclasse le menu dans le viewport et limite sa hauteur à l'espace disponible sous son ancre (le contenu déborde en scroll interne). */ _place() { var el = document.querySelector('.fd-ctx-menu'); if (!el) return; var prev = el.style.maxHeight; el.style.maxHeight = 'none'; var w = el.offsetWidth || 240; var h = el.offsetHeight || 320; el.style.maxHeight = prev || ''; var vw = window.innerWidth, vh = window.innerHeight; var cx = (this._cx == null ? this.x : this._cx); var cy = (this._cy == null ? this.y : this._cy); var nx = cx; if (nx + w > vw - 8) nx = vw - w - 8; nx = Math.max(8, nx); var ny = cy; if (ny + h > vh - 8) { if (cy - h >= 8) ny = cy - h; else ny = Math.max(8, vh - h - 8); } this.x = nx; this.y = ny; this.maxH = Math.max(120, vh - ny - 8); }, close() { if (this._ro) { try { this._ro.disconnect(); } catch { /* volontaire */ } this._ro = null; } this.open = false; this.node = null; this.handlers = {}; this.tagAdding = false; this.tagExistingOpen = false; this.iconOpen = false; }, /* Un item est-il disponible pour CETTE page ? (union vs capacités) */ has(key) { return typeof this.handlers[key] === 'function'; }, /* Exécute l'action → handler fourni par la page courante. */ run(key) { if (!this.node) { this.close(); return; } var fn = this.handlers[key]; if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); } this.close(); }, /* ── Tags ── */ avail() { return this.availTags || []; }, setAvail(a) { this.availTags = a || []; }, removeTag(id) { var fn = this.handlers.tagRemove; if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); } }, addExistingTag(t) { var fn = this.handlers.tagExisting; if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); } this.tagExistingOpen = false; }, addNewTag(name, color) { name = (name || '').trim(); if (!name) return; var fn = this.handlers.tagAdd; if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); } this.tagAdding = false; }, /* ── Icon picker ── */ setIcon(icon) { icon = (icon || '').trim(); var fn = this.handlers.setIcon; if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); } this.close(); }, openIconPicker() { var fn = this.handlers.setIcon; if (!fn) return; if (!window.FDIconPicker) return; window.FDIconPicker.openFor({ x: this.x, y: this.y, onPick: fn, onRemove: function () { fn(''); } }); this.close(); }, }); } if (window.Alpine) { registerFdCtx(); } else { document.addEventListener('alpine:init', registerFdCtx); } })();