frontend/js/ structure: state.js (55 lines) — Shared mutable state, constants utils.js (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons auth.js (547 lines) — api(), AuthManager, initLoginForm, AdminPanel search.js (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch sidebar.js (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags viewer.js (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor ui.js (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu dashboard.js (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks) config.js (999 lines) — Config panel, Hidden files, About, Sidebar tabs sync.js (436 lines) — SSE/IndexUpdateManager, PWA registration graph.js (401 lines) — GraphViewManager (force-directed canvas graph) legacy.js (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch) app.js (80 lines) — Thin orchestrator: imports all modules, calls init() index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js"> Original app.js preserved for backward compatibility. All 14 modules pass node --check syntax validation.
2251 lines
74 KiB
JavaScript
2251 lines
74 KiB
JavaScript
/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */
|
||
import { rightSidebarVisible, rightSidebarWidth, currentVault, currentPath, selectedContextVault } from './state.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Right Sidebar Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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) {
|
||
rightSidebarVisible = savedVisible === "true";
|
||
}
|
||
|
||
if (savedWidth) {
|
||
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() {
|
||
rightSidebarVisible = !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`;
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
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();
|
||
});
|
||
}
|
||
|
||
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
|
||
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");
|
||
});
|
||
}
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
|
||
|
||
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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Context Menu Manager
|
||
// ---------------------------------------------------------------------------
|
||
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 = `
|
||
<i data-lucide="${icon}" class="icon"></i>
|
||
<span>${label}</span>
|
||
`;
|
||
|
||
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() {
|
||
FileOperationsManager.showCreateDirectoryModal(this._targetVault, this._targetPath);
|
||
},
|
||
|
||
_createFile() {
|
||
FileOperationsManager.showCreateFileModal(this._targetVault, this._targetPath);
|
||
},
|
||
|
||
_renameItem() {
|
||
FileOperationsManager.startInlineRename(this._targetVault, this._targetPath, this._targetType);
|
||
},
|
||
|
||
_deleteDirectory() {
|
||
FileOperationsManager.confirmDeleteDirectory(this._targetVault, this._targetPath);
|
||
},
|
||
|
||
_deleteFile() {
|
||
FileOperationsManager.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"); }
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// File Operations Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const FileOperationsManager = {
|
||
showCreateDirectoryModal(vault, parentPath) {
|
||
const overlay = this._createModalOverlay();
|
||
const modal = document.createElement('div');
|
||
modal.className = 'obsigate-modal';
|
||
modal.innerHTML = `
|
||
<div class="obsigate-modal-header">
|
||
<h3 class="obsigate-modal-title">Créer un dossier</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">Nom du dossier</label>
|
||
<input type="text" class="modal-input" id="dir-name-input" placeholder="nouveau-dossier" />
|
||
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
|
||
<div class="modal-error" id="dir-error" style="display:none;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" id="dir-cancel-btn">Annuler</button>
|
||
<button class="modal-btn primary" id="dir-create-btn">Créer</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="obsigate-modal-header">
|
||
<h3 class="obsigate-modal-title">Créer un fichier</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">Nom du fichier</label>
|
||
<input type="text" class="modal-input" id="file-name-input" placeholder="note.md" />
|
||
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
|
||
<div class="modal-error" id="file-error" style="display:none;"></div>
|
||
</div>
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">Type de fichier</label>
|
||
<select class="modal-select" id="file-ext-select">
|
||
<option value=".md">Markdown (.md)</option>
|
||
<option value=".txt">Texte (.txt)</option>
|
||
<option value=".py">Python (.py)</option>
|
||
<option value=".js">JavaScript (.js)</option>
|
||
<option value=".json">JSON (.json)</option>
|
||
<option value=".yaml">YAML (.yaml)</option>
|
||
<option value=".sh">Shell (.sh)</option>
|
||
<option value=".ps1">PowerShell (.ps1)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" id="file-cancel-btn">Annuler</button>
|
||
<button class="modal-btn primary" id="file-create-btn">Créer</button>
|
||
</div>
|
||
`;
|
||
|
||
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' && currentVault === vault && currentPath === path) {
|
||
await openFile(vault, nextPath);
|
||
} else if (type === 'directory' && currentVault === vault && currentPath && (currentPath === path || currentPath.startsWith(`${path}/`))) {
|
||
const suffix = currentPath === path ? '' : currentPath.slice(path.length);
|
||
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 = `
|
||
<div class="obsigate-modal-header">
|
||
<h3 class="obsigate-modal-title">Supprimer le dossier</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-warning">
|
||
<i data-lucide="alert-triangle" class="icon"></i>
|
||
<div>
|
||
<strong>Attention !</strong> Cette action est irréversible.
|
||
<br>Tous les fichiers et sous-dossiers seront supprimés définitivement.
|
||
</div>
|
||
</div>
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">Dossier à supprimer:</label>
|
||
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
||
</div>
|
||
</div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
||
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
||
</div>
|
||
`;
|
||
|
||
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 (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 = `
|
||
<div class="obsigate-modal-header">
|
||
<h3 class="obsigate-modal-title">Supprimer le fichier</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-warning">
|
||
<i data-lucide="alert-triangle" class="icon"></i>
|
||
<div>
|
||
<strong>Attention !</strong> Cette action est irréversible.
|
||
</div>
|
||
</div>
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">Fichier à supprimer:</label>
|
||
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
||
</div>
|
||
</div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
||
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
||
</div>
|
||
`;
|
||
|
||
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 (currentVault === vault && 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
|
||
}
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tab Manager
|
||
// ---------------------------------------------------------------------------
|
||
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
|
||
currentVault = cache.vault;
|
||
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 = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Chargement...</div>';
|
||
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 = `<div style="padding:40px;text-align:center;color:var(--text-error)">Erreur: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
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();
|
||
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(selectedContextVault);
|
||
if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(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 = '<i data-lucide="x" style="width:12px;height:12px"></i>';
|
||
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 = `
|
||
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
|
||
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
|
||
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
|
||
<div class="context-menu-separator"></div>
|
||
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
|
||
`;
|
||
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 });
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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();
|
||
};
|