Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
2617 lines
90 KiB
JavaScript
2617 lines
90 KiB
JavaScript
/* 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, detachInlineEditor } from './utils.js';
|
||
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
|
||
import { GraphViewManager } from './graph.js';
|
||
import { DashboardConflictsWidget } from './dashboard.js';
|
||
import { t, tr } from './i18n.js';
|
||
import { toggleThemeMode } from './themes.js';
|
||
import { isTauriEnv, removeRoot } from './desktop.js';
|
||
// ---------------------------------------------------------------------------
|
||
// Right Sidebar Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const RightSidebarManager = {
|
||
init() {
|
||
this.loadState();
|
||
this.initToggle();
|
||
this.initResize();
|
||
},
|
||
|
||
loadState() {
|
||
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
|
||
|
||
// Le panneau TOC (« Sur cette page ») part toujours ferme a l'ouverture
|
||
// de l'app ; seul le bouton TOC/toggle du panneau l'ouvre. La largeur,
|
||
// elle, est memorisee.
|
||
state.rightSidebarVisible = false;
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
|
||
|
||
export function initTheme() {
|
||
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
|
||
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
|
||
// the persisted mode here so the first paint and the highlight.js stylesheet
|
||
// are right before the async theme init runs (BUG-078).
|
||
let mode = "dark";
|
||
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
|
||
applyTheme(mode);
|
||
}
|
||
|
||
export function applyTheme(theme) {
|
||
let mode = theme;
|
||
if (_THEME_MODES.indexOf(mode) === -1) {
|
||
// Callers may pass a theme key (legacy): fall back to the persisted mode.
|
||
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
|
||
}
|
||
const isDark = mode === "dark";
|
||
document.documentElement.setAttribute("data-theme", mode);
|
||
|
||
// 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", isDark ? "moon" : "sun");
|
||
}
|
||
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
|
||
safeCreateIcons();
|
||
}
|
||
|
||
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
|
||
const darkSheet = document.getElementById("hljs-theme-dark");
|
||
const lightSheet = document.getElementById("hljs-theme-light");
|
||
if (darkSheet && lightSheet) {
|
||
darkSheet.disabled = !isDark;
|
||
lightSheet.disabled = isDark;
|
||
}
|
||
|
||
// Update Mermaid theme for newly rendered diagrams
|
||
import('./mermaid-viewer.js').then(function(m) {
|
||
m.updateMermaidTheme(isDark);
|
||
}).catch(function() {});
|
||
}
|
||
|
||
export function toggleTheme() {
|
||
const newMode = toggleThemeMode();
|
||
// Update the theme toggle label
|
||
const themeLabel = document.getElementById("theme-label");
|
||
if (themeLabel) {
|
||
const modeLabelMap = { dark: 'theme.dark', light: 'theme.light', 'high-contrast': 'theme.high_contrast', sepia: 'theme.sepia' };
|
||
themeLabel.textContent = t(modeLabelMap[newMode] || 'theme.dark');
|
||
}
|
||
// Update the theme toggle icon
|
||
const themeBtn = document.getElementById("theme-toggle");
|
||
if (themeBtn) {
|
||
const icon = themeBtn.querySelector("i");
|
||
if (icon) {
|
||
const isLight = newMode === "light" || newMode === "sepia";
|
||
icon.setAttribute("data-lucide", isLight ? "sun" : "moon");
|
||
}
|
||
}
|
||
safeCreateIcons();
|
||
}
|
||
|
||
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();
|
||
});
|
||
|
||
// API documentation (#72) — opens the OpenAPI/Swagger page in a new tab.
|
||
const apiRow = document.getElementById("api-docs-menu-row");
|
||
if (apiRow) {
|
||
apiRow.addEventListener("click", () => {
|
||
closeHeaderMenu();
|
||
window.open("/docs", "_blank", "noopener");
|
||
});
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// Auto-translate French messages via reverse lookup
|
||
message = tr(message);
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Persist toggle state across document openings
|
||
let _frontmatterOpen = false;
|
||
|
||
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 (persisted across documents)
|
||
let isOpen = _frontmatterOpen;
|
||
|
||
// Build header with chevron
|
||
const chevron = el("span", { class: "fm-chevron " + (isOpen ? "open" : "closed") });
|
||
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]);
|
||
|
||
// Set initial display based on persisted state
|
||
if (!isOpen) acCard.style.display = "none";
|
||
|
||
// Toggle functionality
|
||
fmHeader.addEventListener("click", () => {
|
||
isOpen = !isOpen;
|
||
_frontmatterOpen = 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 = `
|
||
<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">${t('modal.in_folder', {path: 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 = t('modal.creating');
|
||
|
||
try {
|
||
await api(`/api/directory/${encodeURIComponent(vault)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path }),
|
||
});
|
||
|
||
showToast(t('toast.dir_created', {name: name}), 'success');
|
||
this._closeModal(overlay);
|
||
await refreshSidebarTreePreservingState();
|
||
} catch (err) {
|
||
showToast(err.message || t('toast.create_error'), 'error');
|
||
createBtn.disabled = false;
|
||
createBtn.textContent = t('modal.create_btn');
|
||
}
|
||
};
|
||
|
||
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, preselectExt = null) {
|
||
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">${t('modal.create_file')}</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">${t('modal.file_name')}</label>
|
||
<input type="text" class="modal-input" id="file-name-input" placeholder="note.md" />
|
||
<div class="modal-hint">${t('modal.in_folder', {path: parentPath || '/'})}</div>
|
||
<div class="modal-error" id="file-error" style="display:none;"></div>
|
||
</div>
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">${t('modal.file_type')}</label>
|
||
<select class="modal-select" id="file-ext-select">
|
||
<option value=".md">${t('modal.ext_md')}</option>
|
||
<option value=".txt">${t('modal.ext_txt')}</option>
|
||
<option value=".py">${t('modal.ext_py')}</option>
|
||
<option value=".js">${t('modal.ext_js')}</option>
|
||
<option value=".json">${t('modal.ext_json')}</option>
|
||
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
|
||
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
|
||
<option value=".yaml">${t('modal.ext_yaml')}</option>
|
||
<option value=".sh">${t('modal.ext_sh')}</option>
|
||
<option value=".ps1">${t('modal.ext_ps1')}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" id="file-cancel-btn">${t('common.cancel')}</button>
|
||
<button class="modal-btn primary" id="file-create-btn">${t('modal.create_btn')}</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();
|
||
|
||
if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) {
|
||
extSelect.value = preselectExt;
|
||
}
|
||
|
||
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 = t('modal.creating');
|
||
|
||
try {
|
||
await api(`/api/file/${encodeURIComponent(vault)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path, content: '' }),
|
||
});
|
||
|
||
showToast(t('toast.file_created', {name: name}), 'success');
|
||
this._closeModal(overlay);
|
||
await refreshSidebarTreePreservingState();
|
||
openFile(vault, path);
|
||
} catch (err) {
|
||
showToast(err.message || t('toast.create_error'), 'error');
|
||
createBtn.disabled = false;
|
||
createBtn.textContent = t('modal.create_btn');
|
||
}
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
// Find the text span (.tree-item-text) — tree items use spans, not direct text nodes
|
||
const textSpan = item.querySelector('.tree-item-text');
|
||
if (!textSpan) {
|
||
showToast(t('ui.cannot_rename'), 'error');
|
||
return;
|
||
}
|
||
|
||
const originalText = textSpan.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;
|
||
|
||
// Hide the text span, show input
|
||
textSpan.style.display = 'none';
|
||
textSpan.insertAdjacentElement('afterend', input);
|
||
|
||
const restore = () => {
|
||
input.remove();
|
||
textSpan.style.display = '';
|
||
};
|
||
|
||
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' ? t('toast.dir_renamed') : t('toast.file_renamed'), 'success');
|
||
} catch (err) {
|
||
input.disabled = false;
|
||
showToast(err.message || t('toast.rename_error'), '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">${t('modal.delete_dir')}</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-warning">
|
||
<i data-lucide="alert-triangle" class="icon"></i>
|
||
<div>
|
||
<strong>${t('modal.delete_warning')}</strong> ${t('modal.delete_irreversible')}
|
||
<br>${t('modal.delete_dir_warning')}
|
||
</div>
|
||
</div>
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">${t('modal.dir_to_delete')}</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">${t('common.cancel')}</button>
|
||
<button class="modal-btn danger" id="del-confirm-btn">${t('modal.delete_permanently')}</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 = t('modal.creating');
|
||
|
||
try {
|
||
const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
showToast(t('toast.dir_deleted', {count: result.deleted_count}), 'success');
|
||
this._closeModal(overlay);
|
||
await refreshSidebarTreePreservingState();
|
||
|
||
if (state.currentVault === vault && state.currentPath && currentPath.startsWith(path)) {
|
||
showWelcome();
|
||
}
|
||
} catch (err) {
|
||
showToast(err.message || t('toast.delete_error'), 'error');
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.textContent = t('modal.delete_permanently');
|
||
}
|
||
};
|
||
|
||
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">${t('modal.delete_file')}</h3>
|
||
</div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-warning">
|
||
<i data-lucide="alert-triangle" class="icon"></i>
|
||
<div>
|
||
<strong>${t('modal.delete_warning')}</strong> ${t('modal.delete_irreversible')}
|
||
</div>
|
||
</div>
|
||
<div class="modal-form-group">
|
||
<label class="modal-label">${t('modal.file_to_delete')}</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">${t('common.cancel')}</button>
|
||
<button class="modal-btn danger" id="del-confirm-btn">${t('modal.delete_permanently')}</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 = t('modal.creating');
|
||
|
||
try {
|
||
await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
showToast(t('toast.file_deleted'), 'success');
|
||
this._closeModal(overlay);
|
||
await refreshSidebarTreePreservingState();
|
||
|
||
if (state.currentVault === vault && state.currentPath === path) {
|
||
showWelcome();
|
||
}
|
||
} catch (err) {
|
||
showToast(err.message || t('toast.delete_error'), 'error');
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.textContent = t('modal.delete_permanently');
|
||
}
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Generic themed dialogs (#154-A2)
|
||
// ---------------------------------------------------------------------------
|
||
// Promise-based replacements for window.confirm() / window.prompt() so the
|
||
// Excel viewer's structure actions and lossy-write confirmations stay inside
|
||
// the app theme (and are keyboard accessible) instead of native dialogs.
|
||
|
||
function _closeDialog(overlay, resolve, value) {
|
||
overlay.classList.remove("active");
|
||
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
|
||
setTimeout(() => overlay.remove(), 200);
|
||
resolve(value);
|
||
}
|
||
|
||
function _openDialog(innerHtml) {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "obsigate-modal-overlay";
|
||
const modal = document.createElement("div");
|
||
modal.className = "obsigate-modal";
|
||
modal.setAttribute("role", "dialog");
|
||
modal.setAttribute("aria-modal", "true");
|
||
modal.innerHTML = innerHtml;
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
setTimeout(() => overlay.classList.add("active"), 10);
|
||
return { overlay, modal };
|
||
}
|
||
|
||
/** Themed replacement for window.confirm(). Resolves to a boolean. */
|
||
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
|
||
return new Promise((resolve) => {
|
||
const { overlay, modal } = _openDialog(`
|
||
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
|
||
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
|
||
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
|
||
</div>`);
|
||
const done = (v) => _closeDialog(overlay, resolve, v);
|
||
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
|
||
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
|
||
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
|
||
overlay._onKey = (e) => {
|
||
if (e.key === "Escape") done(false);
|
||
else if (e.key === "Enter") done(true);
|
||
};
|
||
document.addEventListener("keydown", overlay._onKey);
|
||
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
|
||
});
|
||
}
|
||
|
||
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
|
||
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
|
||
return new Promise((resolve) => {
|
||
const { overlay, modal } = _openDialog(`
|
||
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
|
||
<div class="obsigate-modal-body">
|
||
<div class="modal-form-group">
|
||
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
|
||
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
|
||
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
|
||
</div>
|
||
</div>
|
||
<div class="obsigate-modal-footer">
|
||
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
|
||
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
|
||
</div>`);
|
||
const input = modal.querySelector('[data-dialog="input"]');
|
||
const done = (v) => _closeDialog(overlay, resolve, v);
|
||
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
|
||
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
|
||
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
|
||
overlay._onKey = (e) => {
|
||
if (e.key === "Escape") done(null);
|
||
else if (e.key === "Enter") done(input.value);
|
||
};
|
||
document.addEventListener("keydown", overlay._onKey);
|
||
setTimeout(() => { input.focus(); input.select(); }, 20);
|
||
});
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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(t("error.initialization"), "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', () => {
|
||
// Un scroll déclenché juste après l'ouverture vient de la reflow des
|
||
// icônes (lucide) : on ne referme pas le menu qui vient d'apparaître.
|
||
if (performance.now() - (this._shownAt || 0) < 250) return;
|
||
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);
|
||
this._addSeparator();
|
||
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, '')); }, false);
|
||
// #159 — retrait d'un vault ou dossier racine : desktop uniquement,
|
||
// déregistration seule (aucun fichier supprimé), confirmation exigée.
|
||
if (isTauriEnv()) {
|
||
this._addSeparator();
|
||
this._addItem('folder-minus', t('desktop.remove_root'), () => {
|
||
const name = this._targetVault;
|
||
if (name) removeRoot(name, t('desktop.remove_root_confirm', { name: name }));
|
||
}, false);
|
||
}
|
||
} 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._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly);
|
||
this._addSeparator();
|
||
this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false);
|
||
this._addSeparator();
|
||
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
|
||
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', t("viewer.bookmark_add"), () => this._toggleBookmark(), false);
|
||
this._addSeparator();
|
||
this._addItem('history', t("ui.backups_versions"), () => this._openBackupViewer(), false);
|
||
}
|
||
|
||
this._menu.classList.add('active');
|
||
this._shownAt = performance.now();
|
||
|
||
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 = t("ui.vault_readonly");
|
||
}
|
||
|
||
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);
|
||
},
|
||
|
||
_createExcalidraw() {
|
||
FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw');
|
||
},
|
||
|
||
_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(t('toast.path_copied', {path: 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(t('toast.path_copied', {path: path}), 'success');
|
||
} else {
|
||
showToast(t('toast.copy_error'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast(t('toast.copy_error'), '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 ? t("toast.bookmark_added") : t("toast.bookmark_removed"), "success");
|
||
if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
|
||
DashboardBookmarkWidget.load();
|
||
}
|
||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
},
|
||
|
||
async _saveDirectorySearch() {
|
||
try {
|
||
var vault = this._targetVault || "all";
|
||
var dirPath = this._targetPath;
|
||
var dirName = dirPath.split("/").pop() || dirPath;
|
||
await api("/api/saved-searches", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
query: "",
|
||
vault: vault,
|
||
case_sensitive: false,
|
||
whole_word: false,
|
||
regex: false,
|
||
include_paths: dirPath + "/**",
|
||
exclude_paths: "",
|
||
}),
|
||
});
|
||
showToast(t("toast.dir_saved_search"), "success");
|
||
// Switch to saved searches tab
|
||
var savedTab = document.getElementById("sidebar-tab-saved");
|
||
if (savedTab) savedTab.click();
|
||
// Reload saved searches
|
||
import('./viewer.js').then(function(m) { m.loadSavedSearches(); });
|
||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
},
|
||
|
||
_openBackupViewer() {
|
||
// Dynamically import the backup viewer module to avoid circular deps
|
||
import('./backups.js').then(mod => {
|
||
mod.openBackupViewer(this._targetVault, this._targetPath);
|
||
}).catch(err => {
|
||
showToast(t("toast.viewer_error") + ": " + err.message, "error");
|
||
});
|
||
this.hide();
|
||
}
|
||
};
|
||
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");
|
||
// Expose on window so pane-manager.js can reference the singleton
|
||
window.TabManager = this;
|
||
window._singletonTabManager = this;
|
||
},
|
||
|
||
/** 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) {
|
||
// Delegate to active pane's TabManager when split
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().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) {
|
||
// Delegate to active pane's TabManager when split
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().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);
|
||
},
|
||
|
||
/** Open (or retarget) the navigation tab for a vault directory (#158).
|
||
* One navigation tab per vault: clicking another directory reuses it and
|
||
* updates its directory, so open file tabs are never displaced. */
|
||
async openNav(vault, dir = "") {
|
||
if (!vault || vault === "all") return;
|
||
dir = dir || "";
|
||
const tabId = `nav::${vault}`;
|
||
const name = dir ? dir.split("/").pop() : vault;
|
||
|
||
const existing = this._tabs.find(t => t.id === tabId);
|
||
if (existing) {
|
||
existing.path = dir;
|
||
existing.name = name;
|
||
this._tabCache[tabId].path = dir;
|
||
this._tabCache[tabId].title = name;
|
||
// Force a re-render when it is already the active tab
|
||
if (this._activeTabId === tabId) this._activeTabId = null;
|
||
this._renderTabs();
|
||
this.activate(tabId);
|
||
return;
|
||
}
|
||
|
||
this._tabs.push({ id: tabId, vault, path: dir, name, icon: "folder", nav: true });
|
||
this._tabCache[tabId] = { vault, path: dir, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "folder", nav: true };
|
||
this._renderTabs();
|
||
this.activate(tabId);
|
||
},
|
||
|
||
/** Onglet Accueil (#158) : la page d'accueil vit dans un onglet épinglé tout
|
||
* à gauche de la barre. Recréé (et remonté en position 0) à chaque clic sur
|
||
* le titre. */
|
||
async openHome() {
|
||
// En split, l'onglet appartient au panneau actif
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().openHome(t("common.home"));
|
||
}
|
||
const tabId = "home";
|
||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
if (idx > 0) {
|
||
const [tab] = this._tabs.splice(idx, 1);
|
||
this._tabs.unshift(tab); // toujours en tête de barre
|
||
} else if (idx === -1) {
|
||
const name = t("common.home");
|
||
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
|
||
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
|
||
}
|
||
this._renderTabs();
|
||
this.activate(tabId);
|
||
},
|
||
|
||
/** Drop the active-tab marker — the dashboard/home page is showing.
|
||
* Keeps the tabs open; clicking one re-activates (and re-renders) it.
|
||
* L'onglet Accueil, lui, reste l'onglet actif quand la page d'accueil
|
||
* s'affiche (il EST la page d'accueil). */
|
||
deactivate() {
|
||
const active = this._activeTabId && this._tabCache[this._activeTabId];
|
||
if (active && active.home) {
|
||
this._renderTabs();
|
||
return;
|
||
}
|
||
this._activeTabId = null;
|
||
this._renderTabs();
|
||
},
|
||
|
||
/** Activate a specific tab */
|
||
async activate(tabId) {
|
||
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
||
|
||
// #93 — an inline edition session owns this content area: release it before
|
||
// the load placeholder / read view wipes it off the DOM.
|
||
detachInlineEditor();
|
||
|
||
// 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;
|
||
|
||
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
|
||
if (cache.home) {
|
||
state.currentVault = null;
|
||
state.currentPath = null;
|
||
showWelcome(); // conserve l'onglet Accueil actif (deactivate())
|
||
if (history.pushState) {
|
||
history.pushState(null, "", "#");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Navigation tab — render the directory view instead of fetching a file
|
||
if (cache.nav) {
|
||
state.currentVault = cache.vault;
|
||
state.currentPath = cache.path || null;
|
||
const navMod = await import("./vaulthome.js");
|
||
await navMod.showVaultHome(cache.vault, cache.path);
|
||
if (history.pushState) {
|
||
history.pushState(null, "", `#/nav/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
|
||
}
|
||
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 = '<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) {
|
||
// Delegate to active pane's TabManager when split
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().close(tabId);
|
||
}
|
||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
if (idx === -1) return;
|
||
const closingTab = this._tabs[idx];
|
||
|
||
this._tabs.splice(idx, 1);
|
||
delete this._tabCache[tabId];
|
||
this._dirtyTabs.delete(tabId);
|
||
|
||
if (window.NowPlaying && closingTab) {
|
||
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
|
||
}
|
||
|
||
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() {
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().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) {
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().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) {
|
||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||
return window.getActiveTabManager().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");
|
||
// #110 — move any playing media to the persistent dock before wiping.
|
||
if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null);
|
||
// 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 = '<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 — JSON format for cross-pane compatibility
|
||
el.addEventListener("dragstart", (e) => {
|
||
const payload = JSON.stringify({ paneId: 0, tabId: tab.id, index: idx });
|
||
e.dataTransfer.setData("text/plain", payload);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
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 raw = e.dataTransfer.getData("text/plain");
|
||
if (!raw) return;
|
||
let dragData;
|
||
try { dragData = JSON.parse(raw); } catch (e2) {
|
||
// Legacy format: plain index string
|
||
const fromIdx = parseInt(raw);
|
||
if (isNaN(fromIdx)) return;
|
||
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);
|
||
return;
|
||
}
|
||
if (dragData.paneId === 0) {
|
||
// Same pane — reorder
|
||
const rect = el.getBoundingClientRect();
|
||
const mid = rect.left + rect.width / 2;
|
||
const toIdx = e.clientX < mid ? idx : idx + 1;
|
||
if (dragData.index !== toIdx && dragData.index !== toIdx - 1)
|
||
this.moveTab(dragData.index, toIdx);
|
||
}
|
||
// Cross-pane transfers handled by PaneManager's container drop zone
|
||
});
|
||
|
||
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="splitRight"><i data-lucide="layout-panel-right" class="icon"></i><span>Diviser à droite</span></div>
|
||
<div class="context-menu-item" data-action="splitDown"><i data-lucide="layout-panel-bottom" class="icon"></i><span>Diviser en bas</span></div>
|
||
<div class="context-menu-separator"></div>
|
||
<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>
|
||
${window.PaneManager && window.PaneManager.isSplit() ? '<div class="context-menu-separator"></div><div class="context-menu-item" data-action="closePane"><i data-lucide="panel-right-close" class="icon"></i><span>Fermer le panneau</span></div>' : ''}
|
||
`;
|
||
document.body.appendChild(menu);
|
||
safeCreateIcons();
|
||
|
||
menu.addEventListener("click", (e) => {
|
||
const action = e.target.closest(".context-menu-item")?.dataset.action;
|
||
if (action === "splitRight") {
|
||
if (window.PaneManager && window.PaneManager._hasAnyTab && window.PaneManager._hasAnyTab()) {
|
||
window.PaneManager.splitRight();
|
||
menu.remove(); return;
|
||
}
|
||
}
|
||
else if (action === "splitDown") {
|
||
if (window.PaneManager && window.PaneManager._hasAnyTab && window.PaneManager._hasAnyTab()) {
|
||
window.PaneManager.splitDown();
|
||
menu.remove(); return;
|
||
}
|
||
}
|
||
else if (action === "closePane") { if (window.PaneManager) { window.PaneManager.closeActivePane(); menu.remove(); return; } }
|
||
else 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 });
|
||
},
|
||
};
|
||
|
||
|
||
|
||
// ---- Modify init to include TabManager ----
|