/* ObsiGate — UI module */ import { api, AuthManager, initLoginForm } from './auth.js'; import { state } from './state.js'; import { openFile, showWelcome, renderFile, el } from './viewer.js'; import { safeCreateIcons, getFileIcon, escapeHtml } from './utils.js'; import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js'; import { GraphViewManager } from './graph.js'; import { DashboardConflictsWidget } from './dashboard.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 (state.rightSidebarVisible) { sidebar.classList.remove("hidden"); sidebar.style.width = `${state.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", state.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", state.rightSidebarWidth); }; handle.addEventListener("mousedown", onMouseDown); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, }; // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- export function initTheme() { const saved = localStorage.getItem("obsigate-theme") || "dark"; applyTheme(saved); } export 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"; } } export function toggleTheme() { const current = document.documentElement.getAttribute("data-theme"); applyTheme(current === "dark" ? "light" : "dark"); } export 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(); }); } export 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 // --------------------------------------------------------------------------- export 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 export 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. */ export 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) // --------------------------------------------------------------------------- export 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 // --------------------------------------------------------------------------- export 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"); }); } export 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) // --------------------------------------------------------------------------- export 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) // --------------------------------------------------------------------------- export 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 // --------------------------------------------------------------------------- export 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 && state.currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) { const suffix = state.currentPath === path ? '' : currentPath.slice(path.length); state.currentPath = `${nextPath}${suffix}`; await focusPathInSidebar(vault, state.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 && state.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 // --------------------------------------------------------------------------- export 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(); } // ---- 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); } } } }); // ===== MISSING MANAGERS (extracted separately) ===== export const ContextMenuManager = { _menu: null, _targetElement: null, _targetVault: null, _targetPath: null, _targetType: null, init() { this._menu = document.createElement('div'); this._menu.className = 'context-menu'; this._menu.id = 'context-menu'; document.body.appendChild(this._menu); document.addEventListener('click', () => this.hide()); document.addEventListener('contextmenu', (e) => { if (!e.target.closest('.tree-item')) { this.hide(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') this.hide(); }); document.addEventListener('scroll', () => this.hide(), true); }, show(x, y, vault, path, type, isReadonly) { this._targetVault = vault; this._targetPath = path; this._targetType = type; this._menu.innerHTML = ''; // Copy path — available for all types const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); // Graph view — available for all types const graphPath = type === 'vault' ? '' : path; this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); this._addSeparator(); if (type === 'vault') { this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); } else if (type === 'directory') { this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); this._addSeparator(); this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); } else if (type === 'file') { this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); this._addSeparator(); this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false); } this._menu.classList.add('active'); const rect = this._menu.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let finalX = x; let finalY = y; if (x + rect.width > viewportWidth) { finalX = viewportWidth - rect.width - 10; } if (y + rect.height > viewportHeight) { finalY = viewportHeight - rect.height - 10; } this._menu.style.left = `${finalX}px`; this._menu.style.top = `${finalY}px`; safeCreateIcons(); }, hide() { if (this._menu) { this._menu.classList.remove('active'); } }, _addItem(icon, label, callback, disabled) { const item = document.createElement('div'); item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); item.innerHTML = ` ${label} `; if (!disabled) { item.addEventListener('click', (e) => { e.stopPropagation(); this.hide(); callback(); }); } else { item.title = 'Vault en lecture seule'; } this._menu.appendChild(item); }, _addSeparator() { const sep = document.createElement('div'); sep.className = 'context-menu-separator'; this._menu.appendChild(sep); }, _createDirectory() { FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath); }, _createFile() { FileOperations.showCreateFileModal(this._targetVault, this._targetPath); }, _renameItem() { FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType); }, _deleteDirectory() { FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath); }, _deleteFile() { FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); }, _copyPath(path) { // Try modern clipboard API first, fall back to execCommand for non-secure contexts if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(path).then(() => { showToast(`Chemin copié : ${path}`, 'success'); }).catch(() => { this._copyPathFallback(path); }); } else { this._copyPathFallback(path); } }, _copyPathFallback(path) { const textarea = document.createElement('textarea'); textarea.value = path; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { const success = document.execCommand('copy'); if (success) { showToast(`Chemin copié : ${path}`, 'success'); } else { showToast('Erreur lors de la copie', 'error'); } } catch (e) { showToast('Erreur lors de la copie', 'error'); } document.body.removeChild(textarea); }, async _toggleBookmark() { try { const data = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }), }); showToast(data.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { DashboardBookmarkWidget.load(); } } catch (err) { showToast("Erreur: " + err.message, "error"); } } }; export const TabManager = { _tabs: [], _activeTabId: null, _previewTabId: null, // single-click preview tab (temporary, replaced on next preview) _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } _tabBar: null, _tabList: null, _dirtyTabs: new Set(), init() { this._tabBar = document.getElementById("tab-bar"); this._tabList = document.getElementById("tab-list"); }, /** Open a file as a preview tab (single-click). * Replaces any existing preview tab. If the file is already * open as a persistent tab, just activates it. */ async openPreview(vault, path) { const tabId = `${vault}::${path}`; // If already open as persistent tab, just activate it const existing = this._tabs.find(t => t.id === tabId && !t.preview); if (existing) { this.activate(tabId); return; } // Close existing preview tab if (this._previewTabId && this._previewTabId !== tabId) { this.close(this._previewTabId); } // If already open as preview, just focus it const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); if (previewExisting) { this.activate(tabId); return; } // Create preview tab const name = path.split("/").pop().replace(/\.md$/i, ""); const icon = getFileIcon(name + ".md"); this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; this._previewTabId = tabId; this._renderTabs(); this.activate(tabId); }, /** Convert a preview tab to a persistent tab (double-click). * If already persistent, opens a new duplicate (same file, different tab). */ async openPersistent(vault, path) { const tabId = `${vault}::${path}`; // If it's already a preview tab, convert it to persistent const previewTab = this._tabs.find(t => t.id === tabId && t.preview); if (previewTab) { previewTab.preview = false; if (this._previewTabId === tabId) { this._previewTabId = null; } this._renderTabs(); this.activate(tabId); return; } // If already persistent, just focus it const existing = this._tabs.find(t => t.id === tabId && !t.preview); if (existing) { this.activate(tabId); return; } // Create a new persistent tab this.open(vault, path); }, /** Open a file in a tab (or focus existing) */ async open(vault, path, options = {}) { const tabId = `${vault}::${path}`; // If already open, just focus it const existing = this._tabs.find(t => t.id === tabId); if (existing) { // Convert preview to persistent if needed if (existing.preview) { existing.preview = false; if (this._previewTabId === tabId) this._previewTabId = null; this._renderTabs(); } this.activate(tabId); return; } // Create new tab const name = path.split("/").pop().replace(/\.md$/i, ""); const icon = getFileIcon(name + ".md"); this._tabs.push({ id: tabId, vault, path, name, icon }); this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; this._renderTabs(); this.activate(tabId); }, /** Activate a specific tab */ async activate(tabId) { if (this._activeTabId === tabId && this._tabs.length > 0) return; // Save current tab state if (this._activeTabId && this._tabCache[this._activeTabId]) { this._saveCurrentTabState(); } this._activeTabId = tabId; this._renderTabs(); // Load tab content const cache = this._tabCache[tabId]; if (!cache) return; // Update global state state.currentVault = cache.vault; state.currentPath = cache.path; syncActiveFileTreeItem(cache.vault, cache.path); const area = document.getElementById("content-area"); if (cache.data) { // Use cached data this._restoreTabContent(cache, area); } else { // Fetch file content area.innerHTML = '
Chargement...
'; try { const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`); cache.data = data; cache.title = data.title; renderFile(cache.data); // Restore source view if needed if (cache.sourceView) { await this._toggleSourceView(cache, area); } if (cache.scrollTop) { area.scrollTop = cache.scrollTop; } } catch (err) { area.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; } } // Update URL hash if (history.pushState) { history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); } // Hide dashboard const dashboard = document.getElementById("dashboard-home"); if (dashboard) dashboard.style.display = "none"; }, /** Close a tab */ close(tabId) { const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; this._tabs.splice(idx, 1); delete this._tabCache[tabId]; this._dirtyTabs.delete(tabId); if (this._tabs.length === 0) { this._activeTabId = null; this._showDashboard(); if (this._tabBar) this._tabBar.hidden = true; } else if (this._activeTabId === tabId) { // Activate adjacent tab const newIdx = Math.min(idx, this._tabs.length - 1); this.activate(this._tabs[newIdx].id); } this._renderTabs(); }, /** Close all tabs */ closeAll() { this._tabs = []; this._tabCache = {}; this._dirtyTabs.clear(); this._activeTabId = null; this._showDashboard(); if (this._tabBar) this._tabBar.hidden = true; }, /** Close tabs to the right */ closeRight(tabId) { const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; const toClose = this._tabs.slice(idx + 1); for (const tab of toClose) { delete this._tabCache[tab.id]; this._dirtyTabs.delete(tab.id); } this._tabs = this._tabs.slice(0, idx + 1); if (!this._tabs.find(t => t.id === this._activeTabId)) { this.activate(tabId); } this._renderTabs(); }, /** Close other tabs */ closeOthers(tabId) { const tab = this._tabs.find(t => t.id === tabId); if (!tab) return; for (const t of this._tabs) { if (t.id !== tabId) { delete this._tabCache[t.id]; this._dirtyTabs.delete(t.id); } } this._tabs = [tab]; this.activate(tabId); this._renderTabs(); }, /** Reorder tabs by drag and drop */ moveTab(fromIdx, toIdx) { if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return; const tab = this._tabs.splice(fromIdx, 1)[0]; this._tabs.splice(toIdx, 0, tab); this._renderTabs(); }, /** Save current tab state before switching */ _saveCurrentTabState() { const cache = this._tabCache[this._activeTabId]; if (!cache) return; const area = document.getElementById("content-area"); const rendered = document.getElementById("file-rendered-content"); cache.scrollTop = area.scrollTop; cache.sourceView = rendered ? rendered.style.display === "none" : false; }, /** Restore tab content from cache */ _restoreTabContent(cache, area) { renderFile(cache.data); if (cache.sourceView) { this._restoreSourceView(cache, area); } if (cache.scrollTop) { area.scrollTop = cache.scrollTop; } }, async _toggleSourceView(cache, area) { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; if (!cache.rawSource) { const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`); cache.rawSource = rawData.raw; } raw.textContent = cache.rawSource; rendered.style.display = "none"; raw.style.display = "block"; }, _restoreSourceView(cache, area) { requestAnimationFrame(() => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (rendered && raw && cache.rawSource) { raw.textContent = cache.rawSource; rendered.style.display = "none"; raw.style.display = "block"; } }); }, _showDashboard() { const area = document.getElementById("content-area"); // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) let dashboard = document.getElementById("dashboard-home"); if (!dashboard) { // Dashboard was destroyed — rebuild via showWelcome area.innerHTML = ""; showWelcome(); return; } area.innerHTML = ""; dashboard.style.display = ""; area.appendChild(dashboard); // Refresh widgets after restoring if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load(); if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load(); if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(state.selectedContextVault); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(state.selectedContextVault); if (history.pushState) { history.pushState(null, "", "#"); } }, /** Render the tab bar */ _renderTabs() { if (!this._tabList) return; this._tabList.innerHTML = ""; if (this._tabs.length === 0) { this._tabBar.hidden = true; return; } this._tabBar.hidden = false; this._tabs.forEach((tab, idx) => { const el = document.createElement("div"); el.className = "tab-item" + (tab.id === this._activeTabId ? " active" : "") + (tab.preview ? " preview" : ""); el.draggable = true; el.dataset.tabId = tab.id; el.dataset.index = idx; // Icon const iconEl = document.createElement("i"); iconEl.setAttribute("data-lucide", tab.icon); iconEl.className = "tab-icon"; iconEl.style.width = "14px"; iconEl.style.height = "14px"; el.appendChild(iconEl); // Name const nameEl = document.createElement("span"); nameEl.className = "tab-name"; nameEl.textContent = tab.name; nameEl.title = `${tab.vault}/${tab.path}`; el.appendChild(nameEl); // Close button const closeEl = document.createElement("span"); closeEl.className = "tab-close"; closeEl.innerHTML = ''; closeEl.addEventListener("click", (e) => { e.stopPropagation(); this.close(tab.id); }); el.appendChild(closeEl); // Click to activate el.addEventListener("click", () => this.activate(tab.id)); // Double-click to close el.addEventListener("dblclick", (e) => { e.preventDefault(); this.close(tab.id); }); // Middle-click to close el.addEventListener("mousedown", (e) => { if (e.button === 1) { e.preventDefault(); this.close(tab.id); } }); // Context menu on tab el.addEventListener("contextmenu", (e) => { e.preventDefault(); this._showTabContextMenu(e.clientX, e.clientY, tab.id); }); // Drag and drop el.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/plain", String(idx)); el.classList.add("dragging"); }); el.addEventListener("dragend", () => { el.classList.remove("dragging"); document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); }); el.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); const indicator = document.createElement("div"); indicator.className = "tab-drop-indicator"; if (e.clientX < mid) { el.before(indicator); } else { el.after(indicator); } }); el.addEventListener("drop", (e) => { e.preventDefault(); document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; const toIdx = e.clientX < mid ? idx : idx + 1; if (fromIdx !== toIdx && fromIdx !== toIdx - 1) { this.moveTab(fromIdx, toIdx); } }); this._tabList.appendChild(el); }); safeCreateIcons(); }, _showTabContextMenu(x, y, tabId) { const existing = document.getElementById("tab-context-menu"); if (existing) existing.remove(); const menu = document.createElement("div"); menu.id = "tab-context-menu"; menu.className = "context-menu active"; menu.style.left = x + "px"; menu.style.top = y + "px"; menu.innerHTML = `
Fermer
Fermer les autres
Fermer à droite
Fermer tout
`; document.body.appendChild(menu); safeCreateIcons(); menu.addEventListener("click", (e) => { const action = e.target.closest(".context-menu-item")?.dataset.action; if (action === "close") this.close(tabId); else if (action === "closeOthers") this.closeOthers(tabId); else if (action === "closeRight") this.closeRight(tabId); else if (action === "closeAll") this.closeAll(); menu.remove(); }); const closeMenu = () => menu.remove(); document.addEventListener("click", closeMenu, { once: true }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { menu.remove(); } }, { once: true }); }, }; // ---- 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 ----