/* ObsiGate — UI module */ import { state } from './state.js'; import { openFile } from './viewer.js'; import { safeCreateIcons } from './utils.js'; // --------------------------------------------------------------------------- // Right Sidebar Manager // --------------------------------------------------------------------------- export const RightSidebarManager = { init() { this.loadState(); this.initToggle(); this.initResize(); }, loadState() { const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible"); const savedWidth = localStorage.getItem("obsigate-right-sidebar-width"); if (savedVisible !== null) { state.rightSidebarVisible = savedVisible === "true"; } if (savedWidth) { state.rightSidebarWidth = parseInt(savedWidth) || 280; } this.applyState(); }, applyState() { const sidebar = document.getElementById("right-sidebar"); const handle = document.getElementById("right-sidebar-resize-handle"); const tocBtn = document.getElementById("toc-toggle-btn"); const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn"); if (!sidebar) return; if (rightSidebarVisible) { sidebar.classList.remove("hidden"); sidebar.style.width = `${rightSidebarWidth}px`; if (handle) handle.classList.remove("hidden"); if (tocBtn) { tocBtn.classList.add("active"); tocBtn.title = "Masquer le sommaire"; } if (headerToggleBtn) { headerToggleBtn.title = "Masquer le panneau"; headerToggleBtn.setAttribute("aria-label", "Masquer le panneau"); } } else { sidebar.classList.add("hidden"); if (handle) handle.classList.add("hidden"); if (tocBtn) { tocBtn.classList.remove("active"); tocBtn.title = "Afficher le sommaire"; } if (headerToggleBtn) { headerToggleBtn.title = "Afficher le panneau"; headerToggleBtn.setAttribute("aria-label", "Afficher le panneau"); } } // Update icons safeCreateIcons(); }, toggle() { state.rightSidebarVisible = !state.rightSidebarVisible; localStorage.setItem("obsigate-right-sidebar-visible", rightSidebarVisible); this.applyState(); }, initToggle() { const toggleBtn = document.getElementById("right-sidebar-toggle-btn"); if (toggleBtn) { toggleBtn.addEventListener("click", () => this.toggle()); } }, initResize() { const handle = document.getElementById("right-sidebar-resize-handle"); const sidebar = document.getElementById("right-sidebar"); if (!handle || !sidebar) return; let isResizing = false; let startX = 0; let startWidth = 0; const onMouseDown = (e) => { isResizing = true; startX = e.clientX; startWidth = sidebar.offsetWidth; handle.classList.add("active"); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }; const onMouseMove = (e) => { if (!isResizing) return; const delta = startX - e.clientX; let newWidth = startWidth + delta; // Constrain width newWidth = Math.max(200, Math.min(400, newWidth)); sidebar.style.width = `${newWidth}px`; state.rightSidebarWidth = newWidth; }; const onMouseUp = () => { if (!isResizing) return; isResizing = false; handle.classList.remove("active"); document.body.style.cursor = ""; document.body.style.userSelect = ""; localStorage.setItem("obsigate-right-sidebar-width", rightSidebarWidth); }; handle.addEventListener("mousedown", onMouseDown); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, }; // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- function initTheme() { const saved = localStorage.getItem("obsigate-theme") || "dark"; applyTheme(saved); } function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("obsigate-theme", theme); // Update theme button icon and label const themeBtn = document.getElementById("theme-toggle"); const themeLabel = document.getElementById("theme-label"); if (themeBtn && themeLabel) { const icon = themeBtn.querySelector("i"); if (icon) { icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun"); } themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair"; safeCreateIcons(); } // Swap highlight.js theme const darkSheet = document.getElementById("hljs-theme-dark"); const lightSheet = document.getElementById("hljs-theme-light"); if (darkSheet && lightSheet) { darkSheet.disabled = theme !== "dark"; lightSheet.disabled = theme !== "light"; } } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme"); applyTheme(current === "dark" ? "light" : "dark"); } function initHeaderMenu() { const menuBtn = document.getElementById("header-menu-btn"); const menuDropdown = document.getElementById("header-menu-dropdown"); if (!menuBtn || !menuDropdown) return; menuBtn.addEventListener("click", (e) => { e.stopPropagation(); menuBtn.classList.toggle("active"); menuDropdown.classList.toggle("active"); }); // Close menu when clicking outside document.addEventListener("click", (e) => { if (!menuDropdown.contains(e.target) && e.target !== menuBtn) { menuBtn.classList.remove("active"); menuDropdown.classList.remove("active"); } }); // Prevent menu from closing when clicking inside menuDropdown.addEventListener("click", (e) => { e.stopPropagation(); }); } function closeHeaderMenu() { const menuBtn = document.getElementById("header-menu-btn"); const menuDropdown = document.getElementById("header-menu-dropdown"); if (!menuBtn || !menuDropdown) return; menuBtn.classList.remove("active"); menuDropdown.classList.remove("active"); } // --------------------------------------------------------------------------- // Custom Dropdowns // --------------------------------------------------------------------------- function initCustomDropdowns() { document.querySelectorAll(".custom-dropdown").forEach((dropdown) => { const trigger = dropdown.querySelector(".custom-dropdown-trigger"); const options = dropdown.querySelectorAll(".custom-dropdown-option"); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const selectedText = dropdown.querySelector(".custom-dropdown-selected"); const menu = dropdown.querySelector(".custom-dropdown-menu"); if (!trigger) return; // Toggle dropdown trigger.addEventListener("click", (e) => { e.stopPropagation(); const isOpen = dropdown.classList.contains("open"); // Close all other dropdowns document.querySelectorAll(".custom-dropdown.open").forEach((d) => { if (d !== dropdown) d.classList.remove("open"); }); dropdown.classList.toggle("open", !isOpen); trigger.setAttribute("aria-expanded", !isOpen); // Position fixed menu for sidebar dropdowns if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) { const rect = trigger.getBoundingClientRect(); menu.style.top = `${rect.bottom + 4}px`; menu.style.left = `${rect.left}px`; menu.style.width = `${rect.width}px`; } }); // Handle option selection options.forEach((option) => { option.addEventListener("click", (e) => { e.stopPropagation(); const value = option.getAttribute("data-value"); const text = option.textContent; // Update hidden input if (hiddenInput) { hiddenInput.value = value; // Trigger change event hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); } // Update selected text if (selectedText) { selectedText.textContent = text; } // Update visual selection options.forEach((opt) => opt.classList.remove("selected")); option.classList.add("selected"); // Close dropdown dropdown.classList.remove("open"); trigger.setAttribute("aria-expanded", "false"); }); }); }); // Close dropdowns when clicking outside document.addEventListener("click", () => { document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => { dropdown.classList.remove("open"); const trigger = dropdown.querySelector(".custom-dropdown-trigger"); if (trigger) trigger.setAttribute("aria-expanded", "false"); }); }); } // Helper to populate custom dropdown options function populateCustomDropdown(dropdownId, optionsList, defaultValue) { const dropdown = document.getElementById(dropdownId); if (!dropdown) return; const optionsContainer = dropdown.querySelector(".custom-dropdown-menu"); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const selectedText = dropdown.querySelector(".custom-dropdown-selected"); if (!optionsContainer) return; // Clear existing options (keep the first one if it's the default) optionsContainer.innerHTML = ""; // Add new options optionsList.forEach((opt) => { const li = document.createElement("li"); li.className = "custom-dropdown-option"; li.setAttribute("role", "option"); li.setAttribute("data-value", opt.value); li.textContent = opt.text; if (opt.value === defaultValue) { li.classList.add("selected"); if (selectedText) selectedText.textContent = opt.text; if (hiddenInput) hiddenInput.value = opt.value; } optionsContainer.appendChild(li); }); // Re-initialize click handlers optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => { option.addEventListener("click", (e) => { e.stopPropagation(); const value = option.getAttribute("data-value"); const text = option.textContent; if (hiddenInput) { hiddenInput.value = value; hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); } if (selectedText) { selectedText.textContent = text; } optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected")); option.classList.add("selected"); dropdown.classList.remove("open"); const trigger = dropdown.querySelector(".custom-dropdown-trigger"); if (trigger) trigger.setAttribute("aria-expanded", "false"); }); }); } // --------------------------------------------------------------------------- // Toast notifications // --------------------------------------------------------------------------- /** Display a brief toast message at the bottom of the viewport. */ function showToast(message, type) { console.log("showToast called with:", message, type); type = type || "info"; let container = document.getElementById("toast-container"); if (!container) { container = document.createElement("div"); container.id = "toast-container"; container.className = "toast-container"; container.setAttribute("aria-live", "polite"); document.body.appendChild(container); } var toast = document.createElement("div"); toast.className = "toast toast-" + type; toast.textContent = message; container.appendChild(toast); // Trigger entrance animation requestAnimationFrame(function () { toast.classList.add("show"); }); setTimeout(function () { toast.classList.remove("show"); toast.addEventListener("transitionend", function () { toast.remove(); }); }, 3500); } // --------------------------------------------------------------------------- // Sidebar toggle (desktop) // --------------------------------------------------------------------------- function initSidebarToggle() { const toggleBtn = document.getElementById("sidebar-toggle-btn"); const sidebar = document.getElementById("sidebar"); const resizeHandle = document.getElementById("sidebar-resize-handle"); if (!toggleBtn || !sidebar || !resizeHandle) return; // Restore saved state const savedState = localStorage.getItem("obsigate-sidebar-hidden"); if (savedState === "true") { sidebar.classList.add("hidden"); resizeHandle.classList.add("hidden"); toggleBtn.classList.add("active"); } toggleBtn.addEventListener("click", () => { const isHidden = sidebar.classList.toggle("hidden"); resizeHandle.classList.toggle("hidden", isHidden); toggleBtn.classList.toggle("active", isHidden); localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false"); }); } // --------------------------------------------------------------------------- // Mobile sidebar // --------------------------------------------------------------------------- function initMobile() { const hamburger = document.getElementById("hamburger-btn"); const overlay = document.getElementById("sidebar-overlay"); const sidebar = document.getElementById("sidebar"); hamburger.addEventListener("click", () => { sidebar.classList.toggle("mobile-open"); overlay.classList.toggle("active"); }); overlay.addEventListener("click", () => { sidebar.classList.remove("mobile-open"); overlay.classList.remove("active"); }); } function closeMobileSidebar() { const sidebar = document.getElementById("sidebar"); const overlay = document.getElementById("sidebar-overlay"); if (sidebar) sidebar.classList.remove("mobile-open"); if (overlay) overlay.classList.remove("active"); } // --------------------------------------------------------------------------- // Resizable sidebar (horizontal) // --------------------------------------------------------------------------- function initSidebarResize() { const handle = document.getElementById("sidebar-resize-handle"); const sidebar = document.getElementById("sidebar"); if (!handle || !sidebar) return; // Restore saved width const savedWidth = localStorage.getItem("obsigate-sidebar-width"); if (savedWidth) { sidebar.style.width = savedWidth + "px"; } let startX = 0; let startWidth = 0; function onMouseMove(e) { const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX))); sidebar.style.width = newWidth + "px"; } function onMouseUp() { document.body.classList.remove("resizing"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startX = e.clientX; startWidth = sidebar.getBoundingClientRect().width; document.body.classList.add("resizing"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Resizable tag section (vertical) // --------------------------------------------------------------------------- function initTagResize() { const handle = document.getElementById("tag-resize-handle"); const tagSection = document.getElementById("tag-cloud-section"); if (!handle || !tagSection) return; // Restore saved height const savedHeight = localStorage.getItem("obsigate-tag-height"); if (savedHeight) { tagSection.style.height = savedHeight + "px"; } let startY = 0; let startHeight = 0; function onMouseMove(e) { // Dragging up increases height, dragging down decreases const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY))); tagSection.style.height = newHeight + "px"; } function onMouseUp() { document.body.classList.remove("resizing-v"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startY = e.clientY; startHeight = tagSection.getBoundingClientRect().height; document.body.classList.add("resizing-v"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Frontmatter Accent Card Builder // --------------------------------------------------------------------------- function buildFrontmatterCard(frontmatter) { // Helper: format date function formatDate(iso) { if (!iso) return "—"; const d = new Date(iso); const date = d.toISOString().slice(0, 10); const time = d.toTimeString().slice(0, 5); return `${date} · ${time}`; } // Extract boolean flags const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); // Toggle state let isOpen = true; // Build header with chevron const chevron = el("span", { class: "fm-chevron open" }); chevron.innerHTML = ''; const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); // ZONE 1: Top strip const topBadges = []; // Title badge const title = frontmatter.titre || frontmatter.title || ""; if (title) { topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); } // Status badge if (frontmatter.statut) { const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); topBadges.push(statusBadge); } // Category badge if (frontmatter.catégorie || frontmatter.categorie) { const cat = frontmatter.catégorie || frontmatter.categorie; const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); topBadges.push(catBadge); } // Publish badge if (frontmatter.publish) { topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); } // Favoris badge if (frontmatter.favoris) { topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); } const acTop = el("div", { class: "ac-top" }, topBadges); // ZONE 2: Body 2 columns const leftCol = el("div", { class: "ac-col" }, [ el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]), ]); const rightCol = el("div", { class: "ac-col" }, [ el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), ]); const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); // ZONE 3: Tags row const tagPills = []; if (frontmatter.tags && frontmatter.tags.length > 0) { frontmatter.tags.forEach((tag) => { tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); }); } const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); // ZONE 4: Flags row const flagChips = []; booleanFlags.forEach((flag) => { const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); }); const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); // Assemble the card const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); // Toggle functionality fmHeader.addEventListener("click", () => { isOpen = !isOpen; if (isOpen) { acCard.style.display = "block"; chevron.classList.remove("closed"); chevron.classList.add("open"); } else { acCard.style.display = "none"; chevron.classList.remove("open"); chevron.classList.add("closed"); } safeCreateIcons(); }); // Wrap in section const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); return fmSection; } // --------------------------------------------------------------------------- // File Operations Manager // --------------------------------------------------------------------------- const FileOperations = { showCreateDirectoryModal(vault, parentPath) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

Créer un dossier

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); const input = modal.querySelector('#dir-name-input'); const errorDiv = modal.querySelector('#dir-error'); const createBtn = modal.querySelector('#dir-create-btn'); const cancelBtn = modal.querySelector('#dir-cancel-btn'); input.focus(); const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; input.addEventListener('input', () => { const error = validateName(input.value); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; input.classList.add('error'); } else { errorDiv.style.display = 'none'; input.classList.remove('error'); } }); const create = async () => { const name = input.value.trim(); const error = validateName(name); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; return; } const path = parentPath ? `${parentPath}/${name}` : name; createBtn.disabled = true; createBtn.textContent = 'Création...'; try { await api(`/api/directory/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }); showToast(`Dossier "${name}" créé`, 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); } catch (err) { showToast(err.message || 'Erreur lors de la création', 'error'); createBtn.disabled = false; createBtn.textContent = 'Créer'; } }; createBtn.addEventListener('click', create); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') this._closeModal(overlay); }); }, showCreateFileModal(vault, parentPath) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

Créer un fichier

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); const input = modal.querySelector('#file-name-input'); const extSelect = modal.querySelector('#file-ext-select'); const errorDiv = modal.querySelector('#file-error'); const createBtn = modal.querySelector('#file-create-btn'); const cancelBtn = modal.querySelector('#file-cancel-btn'); input.focus(); const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; input.addEventListener('input', () => { const error = validateName(input.value); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; input.classList.add('error'); } else { errorDiv.style.display = 'none'; input.classList.remove('error'); } }); const create = async () => { let name = input.value.trim(); const error = validateName(name); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; return; } const ext = extSelect.value; if (!name.endsWith(ext)) { name += ext; } const path = parentPath ? `${parentPath}/${name}` : name; createBtn.disabled = true; createBtn.textContent = 'Création...'; try { await api(`/api/file/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content: '' }), }); showToast(`Fichier "${name}" créé`, 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); openFile(vault, path); } catch (err) { showToast(err.message || 'Erreur lors de la création', 'error'); createBtn.disabled = false; createBtn.textContent = 'Créer'; } }; createBtn.addEventListener('click', create); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') this._closeModal(overlay); }); }, async startInlineRename(vault, path, type) { const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`); if (!item) { showToast('Élément introuvable dans l’arborescence', 'error'); return; } const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); if (!textNode) { showToast('Impossible de renommer cet élément', 'error'); return; } const originalText = textNode.textContent; const trimmedOriginal = originalText.trim(); const currentName = path.split('/').pop() || trimmedOriginal; const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName; const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : ''; const input = document.createElement('input'); input.type = 'text'; input.className = 'sidebar-item-input'; input.value = baseName; textNode.textContent = ' '; const badge = item.querySelector('.badge-small'); if (badge) { item.insertBefore(input, badge); } else { item.appendChild(input); } const restore = () => { input.remove(); textNode.textContent = originalText; }; const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; const submit = async () => { const name = input.value.trim(); const error = validateName(name); if (error) { showToast(error, 'error'); input.focus(); input.select(); return; } const newName = `${name}${extension}`; if (newName === currentName) { restore(); return; } input.disabled = true; try { const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`; const result = await api(endpoint, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, new_name: newName }), }); const nextPath = result.new_path; await refreshSidebarTreePreservingState(); if (type === 'file' && state.currentVault === vault && state.currentPath === path) { await openFile(vault, nextPath); } else if (type === 'directory' && state.currentVault === vault && currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) { const suffix = state.currentPath === path ? '' : currentPath.slice(path.length); state.currentPath = `${nextPath}${suffix}`; await focusPathInSidebar(vault, currentPath, { alignToTop: false }); } showToast(type === 'directory' ? 'Dossier renommé' : 'Fichier renommé', 'success'); } catch (err) { input.disabled = false; showToast(err.message || 'Erreur lors du renommage', 'error'); input.focus(); input.select(); return; } }; input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', async (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); await submit(); } if (e.key === 'Escape') { e.preventDefault(); restore(); } }); input.addEventListener('blur', async () => { if (!input.disabled) { await submit(); } }); input.focus(); input.setSelectionRange(0, input.value.length); }, confirmDeleteDirectory(vault, path) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

Supprimer le dossier

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); safeCreateIcons(); const confirmBtn = modal.querySelector('#del-confirm-btn'); const cancelBtn = modal.querySelector('#del-cancel-btn'); const deleteDir = async () => { confirmBtn.disabled = true; confirmBtn.textContent = 'Suppression...'; try { const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); if (state.currentVault === vault && currentPath && currentPath.startsWith(path)) { showWelcome(); } } catch (err) { showToast(err.message || 'Erreur lors de la suppression', 'error'); confirmBtn.disabled = false; confirmBtn.textContent = 'Supprimer définitivement'; } }; confirmBtn.addEventListener('click', deleteDir); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); }, confirmDeleteFile(vault, path) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

Supprimer le fichier

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); safeCreateIcons(); const confirmBtn = modal.querySelector('#del-confirm-btn'); const cancelBtn = modal.querySelector('#del-cancel-btn'); const deleteFile = async () => { confirmBtn.disabled = true; confirmBtn.textContent = 'Suppression...'; try { await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); showToast('Fichier supprimé', 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); if (state.currentVault === vault && state.currentPath === path) { showWelcome(); } } catch (err) { showToast(err.message || 'Erreur lors de la suppression', 'error'); confirmBtn.disabled = false; confirmBtn.textContent = 'Supprimer définitivement'; } }; confirmBtn.addEventListener('click', deleteFile); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); }, _createModalOverlay() { const overlay = document.createElement('div'); overlay.className = 'obsigate-modal-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) { this._closeModal(overlay); } }); return overlay; }, _closeModal(overlay) { overlay.classList.remove('active'); setTimeout(() => overlay.remove(), 200); } }; // --------------------------------------------------------------------------- // Find in Page Manager // --------------------------------------------------------------------------- const FindInPageManager = { isOpen: false, searchTerm: "", matches: [], currentIndex: -1, options: { caseSensitive: false, wholeWord: false, useRegex: false, }, debounceTimer: null, previousFocus: null, init() { const bar = document.getElementById("find-in-page-bar"); const input = document.getElementById("find-input"); const prevBtn = document.getElementById("find-prev"); const nextBtn = document.getElementById("find-next"); const closeBtn = document.getElementById("find-close"); const caseSensitiveBtn = document.getElementById("find-case-sensitive"); const wholeWordBtn = document.getElementById("find-whole-word"); const regexBtn = document.getElementById("find-regex"); if (!bar || !input) return; // Keyboard shortcuts document.addEventListener("keydown", (e) => { // Ctrl+F or Cmd+F to open if ((e.ctrlKey || e.metaKey) && e.key === "f") { e.preventDefault(); this.open(); } // Escape to close if (e.key === "Escape" && this.isOpen) { e.preventDefault(); this.close(); } // Enter to go to next if (e.key === "Enter" && this.isOpen && document.activeElement === input) { e.preventDefault(); if (e.shiftKey) { this.goToPrevious(); } else { this.goToNext(); } } // F3 for next/previous if (e.key === "F3" && this.isOpen) { e.preventDefault(); if (e.shiftKey) { this.goToPrevious(); } else { this.goToNext(); } } }); // Input event with debounce input.addEventListener("input", (e) => { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.search(e.target.value); }, 250); }); // Navigation buttons prevBtn.addEventListener("click", () => this.goToPrevious()); nextBtn.addEventListener("click", () => this.goToNext()); // Close button closeBtn.addEventListener("click", () => this.close()); // Option toggles caseSensitiveBtn.addEventListener("click", () => { this.options.caseSensitive = !this.options.caseSensitive; caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); wholeWordBtn.addEventListener("click", () => { this.options.wholeWord = !this.options.wholeWord; wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); regexBtn.addEventListener("click", () => { this.options.useRegex = !this.options.useRegex; regexBtn.setAttribute("aria-pressed", this.options.useRegex); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); // Load saved state this.loadState(); }, open() { const bar = document.getElementById("find-in-page-bar"); const input = document.getElementById("find-input"); if (!bar || !input) return; this.previousFocus = document.activeElement; this.isOpen = true; bar.hidden = false; input.focus(); input.select(); safeCreateIcons(); }, close() { const bar = document.getElementById("find-in-page-bar"); if (!bar) return; this.isOpen = false; bar.hidden = true; this.clearHighlights(); this.matches = []; this.currentIndex = -1; this.searchTerm = ""; // Restore previous focus if (this.previousFocus && this.previousFocus.focus) { this.previousFocus.focus(); } }, search(term) { this.searchTerm = term; this.clearHighlights(); this.hideError(); if (!term || term.trim().length === 0) { this.updateCounter(); this.updateNavButtons(); return; } const contentArea = document.querySelector(".md-content"); if (!contentArea) { this.updateCounter(); this.updateNavButtons(); return; } try { const regex = this.createRegex(term); this.matches = []; this.findMatches(contentArea, regex); this.currentIndex = this.matches.length > 0 ? 0 : -1; this.highlightMatches(); this.updateCounter(); this.updateNavButtons(); if (this.matches.length > 0) { this.scrollToMatch(0); } } catch (err) { this.showError(err.message); this.matches = []; this.currentIndex = -1; this.updateCounter(); this.updateNavButtons(); } }, createRegex(term) { let pattern = term; if (!this.options.useRegex) { // Escape special regex characters pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } if (this.options.wholeWord) { pattern = "\\b" + pattern + "\\b"; } const flags = this.options.caseSensitive ? "g" : "gi"; return new RegExp(pattern, flags); }, findMatches(container, regex) { const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Skip code blocks, scripts, styles const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; const tagName = parent.tagName.toLowerCase(); if (["code", "pre", "script", "style"].includes(tagName)) { return NodeFilter.FILTER_REJECT; } // Skip empty text nodes if (!node.textContent || node.textContent.trim().length === 0) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, }); let node; while ((node = walker.nextNode())) { const text = node.textContent; let match; regex.lastIndex = 0; // Reset regex while ((match = regex.exec(text)) !== null) { this.matches.push({ node: node, index: match.index, length: match[0].length, text: match[0], }); // Prevent infinite loop with zero-width matches if (match.index === regex.lastIndex) { regex.lastIndex++; } } } }, highlightMatches() { const matchesByNode = new Map(); this.matches.forEach((match, idx) => { if (!matchesByNode.has(match.node)) { matchesByNode.set(match.node, []); } matchesByNode.get(match.node).push({ match, idx }); }); matchesByNode.forEach((entries, node) => { if (!node || !node.parentNode) return; const text = node.textContent || ""; let cursor = 0; const fragment = document.createDocumentFragment(); entries.sort((a, b) => a.match.index - b.match.index); entries.forEach(({ match, idx }) => { if (match.index > cursor) { fragment.appendChild(document.createTextNode(text.substring(cursor, match.index))); } const matchText = text.substring(match.index, match.index + match.length); const mark = document.createElement("mark"); mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight"; mark.textContent = matchText; mark.setAttribute("data-find-index", idx); fragment.appendChild(mark); match.element = mark; cursor = match.index + match.length; }); if (cursor < text.length) { fragment.appendChild(document.createTextNode(text.substring(cursor))); } node.parentNode.replaceChild(fragment, node); }); }, clearHighlights() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return; const marks = contentArea.querySelectorAll("mark.find-highlight"); marks.forEach((mark) => { if (!mark.parentNode) return; const text = mark.textContent; const textNode = document.createTextNode(text); mark.parentNode.replaceChild(textNode, mark); }); // Normalize text nodes to merge adjacent text nodes contentArea.normalize(); }, goToNext() { if (this.matches.length === 0) return; // Remove active class from current if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); } // Move to next (with wrapping) this.currentIndex = (this.currentIndex + 1) % this.matches.length; // Add active class to new current if (this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.add("find-highlight-active"); } this.scrollToMatch(this.currentIndex); this.updateCounter(); }, goToPrevious() { if (this.matches.length === 0) return; // Remove active class from current if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); } // Move to previous (with wrapping) this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; // Add active class to new current if (this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.add("find-highlight-active"); } this.scrollToMatch(this.currentIndex); this.updateCounter(); }, scrollToMatch(index) { if (index < 0 || index >= this.matches.length) return; const match = this.matches[index]; if (!match.element) return; const contentArea = document.getElementById("content-area"); if (!contentArea) { match.element.scrollIntoView({ behavior: "smooth", block: "center" }); return; } // Calculate position with offset for header const elementTop = match.element.offsetTop; const offset = 100; // Offset for header contentArea.scrollTo({ top: elementTop - offset, behavior: "smooth", }); }, updateCounter() { const counter = document.getElementById("find-counter"); if (!counter) return; const count = this.matches.length; if (count === 0) { counter.textContent = "0 occurrence"; } else if (count === 1) { counter.textContent = "1 occurrence"; } else { counter.textContent = `${count} occurrences`; } }, updateNavButtons() { const prevBtn = document.getElementById("find-prev"); const nextBtn = document.getElementById("find-next"); if (!prevBtn || !nextBtn) return; const hasMatches = this.matches.length > 0; prevBtn.disabled = !hasMatches; nextBtn.disabled = !hasMatches; }, showError(message) { const errorEl = document.getElementById("find-error"); if (!errorEl) return; errorEl.textContent = message; errorEl.hidden = false; }, hideError() { const errorEl = document.getElementById("find-error"); if (!errorEl) return; errorEl.hidden = true; }, saveState() { try { const state = { options: this.options, }; localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state)); } catch (e) { // Ignore localStorage errors } }, loadState() { try { const saved = localStorage.getItem("obsigate-find-in-page-state"); if (saved) { const state = JSON.parse(saved); if (state.options) { this.options = { ...this.options, ...state.options }; // Update button states const caseSensitiveBtn = document.getElementById("find-case-sensitive"); const wholeWordBtn = document.getElementById("find-whole-word"); const regexBtn = document.getElementById("find-regex"); if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex); } } } catch (e) { // Ignore localStorage errors } }, }; // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { initTheme(); initHeaderMenu(); initCustomDropdowns(); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("header-logo").addEventListener("click", goHome); const refreshBtn = document.getElementById("header-refresh-btn"); if (refreshBtn) refreshBtn.addEventListener("click", goHome); initSearch(); initSidebarToggle(); initMobile(); initVaultContext(); initSidebarTabs(); initHelpModal(); initConfigModal(); initSidebarFilter(); initSidebarResize(); initEditor(); initLoginForm(); initRecentTab(); RightSidebarManager.init(); FindInPageManager.init(); ContextMenuManager.init(); // Check auth status first const authOk = await AuthManager.initAuth(); if (authOk) { // Start SSE sync AFTER auth is established (cookie available) initSyncStatus(); try { await Promise.all([loadVaultSettings(), loadVaults(), loadTags()]); // Initialize dashboard widgets now that vaults are loaded if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.init(); } // Check for popup mode query parameter const urlParams = new URLSearchParams(window.location.search); if (urlParams.get("popup") === "true") { document.body.classList.add("popup-mode"); } // Handle direct deep-link to file via #file=... if (window.location.hash && window.location.hash.startsWith("#file=")) { const hashVal = window.location.hash.substring(6); const sepIndex = hashVal.indexOf(":"); if (sepIndex > -1) { const vault = decodeURIComponent(hashVal.substring(0, sepIndex)); const path = decodeURIComponent(hashVal.substring(sepIndex + 1)); openFile(vault, path); } } else if (urlParams.get("popup") !== "true") { // Default to dashboard if no deep link and not in popup mode showWelcome(); } } catch (err) { console.error("Failed to initialize ObsiGate:", err); showToast("Erreur lors de l'initialisation", "error"); } } safeCreateIcons(); } // ---- Modify openFile to use TabManager ---- const _originalOpenFile = openFile; openFile = function(vault, path) { TabManager.open(vault, path); }; // ---- Keyboard shortcuts for tabs ---- document.addEventListener("keydown", (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === "w" || e.key === "W") { e.preventDefault(); if (TabManager._activeTabId) { TabManager.close(TabManager._activeTabId); } } else if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); const tabs = TabManager._tabs; const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); if (currentIdx >= 0 && tabs.length > 1) { const nextIdx = (currentIdx + 1) % tabs.length; TabManager.activate(tabs[nextIdx].id); } } else if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); const tabs = TabManager._tabs; const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); if (currentIdx >= 0 && tabs.length > 1) { const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length; TabManager.activate(tabs[prevIdx].id); } } } }); // ---- Modify init to include TabManager ---- const _origInit2 = init; init = function() { _origInit2(); TabManager.init(); };