1298 lines
40 KiB
JavaScript
1298 lines
40 KiB
JavaScript
1|/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */
|
|
import { state } from './state.js';
|
|
import { openFile } from './viewer.js';
|
|
import { safeCreateIcons } from './utils.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Right Sidebar Manager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const RightSidebarManager = {
|
|
init() {
|
|
this.loadState();
|
|
this.initToggle();
|
|
this.initResize();
|
|
},
|
|
|
|
loadState() {
|
|
const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
|
|
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
|
|
|
|
if (savedVisible !== null) {
|
|
state.rightSidebarVisible = savedVisible === "true";
|
|
}
|
|
|
|
if (savedWidth) {
|
|
state.rightSidebarWidth = parseInt(savedWidth) || 280;
|
|
}
|
|
|
|
this.applyState();
|
|
},
|
|
|
|
applyState() {
|
|
const sidebar = document.getElementById("right-sidebar");
|
|
const handle = document.getElementById("right-sidebar-resize-handle");
|
|
const tocBtn = document.getElementById("toc-toggle-btn");
|
|
const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn");
|
|
|
|
if (!sidebar) return;
|
|
|
|
if (state.rightSidebarVisible) {
|
|
sidebar.classList.remove("hidden");
|
|
sidebar.style.width = `${state.rightSidebarWidth}px`;
|
|
if (handle) handle.classList.remove("hidden");
|
|
if (tocBtn) {
|
|
tocBtn.classList.add("active");
|
|
tocBtn.title = "Masquer le sommaire";
|
|
}
|
|
if (headerToggleBtn) {
|
|
headerToggleBtn.title = "Masquer le panneau";
|
|
headerToggleBtn.setAttribute("aria-label", "Masquer le panneau");
|
|
}
|
|
} else {
|
|
sidebar.classList.add("hidden");
|
|
if (handle) handle.classList.add("hidden");
|
|
if (tocBtn) {
|
|
tocBtn.classList.remove("active");
|
|
tocBtn.title = "Afficher le sommaire";
|
|
}
|
|
if (headerToggleBtn) {
|
|
headerToggleBtn.title = "Afficher le panneau";
|
|
headerToggleBtn.setAttribute("aria-label", "Afficher le panneau");
|
|
}
|
|
}
|
|
|
|
// Update icons
|
|
safeCreateIcons();
|
|
},
|
|
|
|
toggle() {
|
|
state.rightSidebarVisible = !state.rightSidebarVisible;
|
|
localStorage.setItem("obsigate-right-sidebar-visible", state.rightSidebarVisible);
|
|
this.applyState();
|
|
},
|
|
|
|
initToggle() {
|
|
const toggleBtn = document.getElementById("right-sidebar-toggle-btn");
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener("click", () => this.toggle());
|
|
}
|
|
},
|
|
|
|
initResize() {
|
|
const handle = document.getElementById("right-sidebar-resize-handle");
|
|
const sidebar = document.getElementById("right-sidebar");
|
|
|
|
if (!handle || !sidebar) return;
|
|
|
|
let isResizing = false;
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
|
|
const onMouseDown = (e) => {
|
|
isResizing = true;
|
|
startX = e.clientX;
|
|
startWidth = sidebar.offsetWidth;
|
|
handle.classList.add("active");
|
|
document.body.style.cursor = "ew-resize";
|
|
document.body.style.userSelect = "none";
|
|
};
|
|
|
|
const onMouseMove = (e) => {
|
|
if (!isResizing) return;
|
|
|
|
const delta = startX - e.clientX;
|
|
let newWidth = startWidth + delta;
|
|
|
|
// Constrain width
|
|
newWidth = Math.max(200, Math.min(400, newWidth));
|
|
|
|
sidebar.style.width = `${newWidth}px`;
|
|
state.rightSidebarWidth = newWidth;
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
if (!isResizing) return;
|
|
|
|
isResizing = false;
|
|
handle.classList.remove("active");
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
|
|
localStorage.setItem("obsigate-right-sidebar-width", state.rightSidebarWidth);
|
|
};
|
|
|
|
handle.addEventListener("mousedown", onMouseDown);
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Theme
|
|
// ---------------------------------------------------------------------------
|
|
export function initTheme() {
|
|
const saved = localStorage.getItem("obsigate-theme") || "dark";
|
|
applyTheme(saved);
|
|
}
|
|
|
|
export function applyTheme(theme) {
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
localStorage.setItem("obsigate-theme", theme);
|
|
|
|
// Update theme button icon and label
|
|
const themeBtn = document.getElementById("theme-toggle");
|
|
const themeLabel = document.getElementById("theme-label");
|
|
if (themeBtn && themeLabel) {
|
|
const icon = themeBtn.querySelector("i");
|
|
if (icon) {
|
|
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
|
|
}
|
|
themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair";
|
|
safeCreateIcons();
|
|
}
|
|
|
|
// Swap highlight.js theme
|
|
const darkSheet = document.getElementById("hljs-theme-dark");
|
|
const lightSheet = document.getElementById("hljs-theme-light");
|
|
if (darkSheet && lightSheet) {
|
|
darkSheet.disabled = theme !== "dark";
|
|
lightSheet.disabled = theme !== "light";
|
|
}
|
|
}
|
|
|
|
export function toggleTheme() {
|
|
const current = document.documentElement.getAttribute("data-theme");
|
|
applyTheme(current === "dark" ? "light" : "dark");
|
|
}
|
|
|
|
export function initHeaderMenu() {
|
|
const menuBtn = document.getElementById("header-menu-btn");
|
|
const menuDropdown = document.getElementById("header-menu-dropdown");
|
|
|
|
if (!menuBtn || !menuDropdown) return;
|
|
|
|
menuBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
menuBtn.classList.toggle("active");
|
|
menuDropdown.classList.toggle("active");
|
|
});
|
|
|
|
// Close menu when clicking outside
|
|
document.addEventListener("click", (e) => {
|
|
if (!menuDropdown.contains(e.target) && e.target !== menuBtn) {
|
|
menuBtn.classList.remove("active");
|
|
menuDropdown.classList.remove("active");
|
|
}
|
|
});
|
|
|
|
// Prevent menu from closing when clicking inside
|
|
menuDropdown.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
}
|
|
|
|
function closeHeaderMenu() {
|
|
const menuBtn = document.getElementById("header-menu-btn");
|
|
const menuDropdown = document.getElementById("header-menu-dropdown");
|
|
if (!menuBtn || !menuDropdown) return;
|
|
menuBtn.classList.remove("active");
|
|
menuDropdown.classList.remove("active");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom Dropdowns
|
|
// ---------------------------------------------------------------------------
|
|
export function initCustomDropdowns() {
|
|
document.querySelectorAll(".custom-dropdown").forEach((dropdown) => {
|
|
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
|
|
const options = dropdown.querySelectorAll(".custom-dropdown-option");
|
|
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
|
|
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
|
|
const menu = dropdown.querySelector(".custom-dropdown-menu");
|
|
|
|
if (!trigger) return;
|
|
|
|
// Toggle dropdown
|
|
trigger.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = dropdown.classList.contains("open");
|
|
|
|
// Close all other dropdowns
|
|
document.querySelectorAll(".custom-dropdown.open").forEach((d) => {
|
|
if (d !== dropdown) d.classList.remove("open");
|
|
});
|
|
|
|
dropdown.classList.toggle("open", !isOpen);
|
|
trigger.setAttribute("aria-expanded", !isOpen);
|
|
|
|
// Position fixed menu for sidebar dropdowns
|
|
if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) {
|
|
const rect = trigger.getBoundingClientRect();
|
|
menu.style.top = `${rect.bottom + 4}px`;
|
|
menu.style.left = `${rect.left}px`;
|
|
menu.style.width = `${rect.width}px`;
|
|
}
|
|
});
|
|
|
|
// Handle option selection
|
|
options.forEach((option) => {
|
|
option.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const value = option.getAttribute("data-value");
|
|
const text = option.textContent;
|
|
|
|
// Update hidden input
|
|
if (hiddenInput) {
|
|
hiddenInput.value = value;
|
|
// Trigger change event
|
|
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
// Update selected text
|
|
if (selectedText) {
|
|
selectedText.textContent = text;
|
|
}
|
|
|
|
// Update visual selection
|
|
options.forEach((opt) => opt.classList.remove("selected"));
|
|
option.classList.add("selected");
|
|
|
|
// Close dropdown
|
|
dropdown.classList.remove("open");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
});
|
|
});
|
|
});
|
|
|
|
// Close dropdowns when clicking outside
|
|
document.addEventListener("click", () => {
|
|
document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => {
|
|
dropdown.classList.remove("open");
|
|
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
|
|
if (trigger) trigger.setAttribute("aria-expanded", "false");
|
|
});
|
|
});
|
|
}
|
|
|
|
// Helper to populate custom dropdown options
|
|
function populateCustomDropdown(dropdownId, optionsList, defaultValue) {
|
|
const dropdown = document.getElementById(dropdownId);
|
|
if (!dropdown) return;
|
|
|
|
const optionsContainer = dropdown.querySelector(".custom-dropdown-menu");
|
|
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
|
|
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
|
|
|
|
if (!optionsContainer) return;
|
|
|
|
// Clear existing options (keep the first one if it's the default)
|
|
optionsContainer.innerHTML = "";
|
|
|
|
// Add new options
|
|
optionsList.forEach((opt) => {
|
|
const li = document.createElement("li");
|
|
li.className = "custom-dropdown-option";
|
|
li.setAttribute("role", "option");
|
|
li.setAttribute("data-value", opt.value);
|
|
li.textContent = opt.text;
|
|
if (opt.value === defaultValue) {
|
|
li.classList.add("selected");
|
|
if (selectedText) selectedText.textContent = opt.text;
|
|
if (hiddenInput) hiddenInput.value = opt.value;
|
|
}
|
|
optionsContainer.appendChild(li);
|
|
});
|
|
|
|
// Re-initialize click handlers
|
|
optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => {
|
|
option.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const value = option.getAttribute("data-value");
|
|
const text = option.textContent;
|
|
|
|
if (hiddenInput) {
|
|
hiddenInput.value = value;
|
|
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
if (selectedText) {
|
|
selectedText.textContent = text;
|
|
}
|
|
|
|
optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected"));
|
|
option.classList.add("selected");
|
|
|
|
dropdown.classList.remove("open");
|
|
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
|
|
if (trigger) trigger.setAttribute("aria-expanded", "false");
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toast notifications
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Display a brief toast message at the bottom of the viewport. */
|
|
export function showToast(message, type) {
|
|
console.log("showToast called with:", message, type);
|
|
type = type || "info";
|
|
let container = document.getElementById("toast-container");
|
|
if (!container) {
|
|
container = document.createElement("div");
|
|
container.id = "toast-container";
|
|
container.className = "toast-container";
|
|
container.setAttribute("aria-live", "polite");
|
|
document.body.appendChild(container);
|
|
}
|
|
var toast = document.createElement("div");
|
|
toast.className = "toast toast-" + type;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
// Trigger entrance animation
|
|
requestAnimationFrame(function () {
|
|
toast.classList.add("show");
|
|
});
|
|
setTimeout(function () {
|
|
toast.classList.remove("show");
|
|
toast.addEventListener("transitionend", function () {
|
|
toast.remove();
|
|
});
|
|
}, 3500);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sidebar toggle (desktop)
|
|
// ---------------------------------------------------------------------------
|
|
export function initSidebarToggle() {
|
|
const toggleBtn = document.getElementById("sidebar-toggle-btn");
|
|
const sidebar = document.getElementById("sidebar");
|
|
const resizeHandle = document.getElementById("sidebar-resize-handle");
|
|
|
|
if (!toggleBtn || !sidebar || !resizeHandle) return;
|
|
|
|
// Restore saved state
|
|
const savedState = localStorage.getItem("obsigate-sidebar-hidden");
|
|
if (savedState === "true") {
|
|
sidebar.classList.add("hidden");
|
|
resizeHandle.classList.add("hidden");
|
|
toggleBtn.classList.add("active");
|
|
}
|
|
|
|
toggleBtn.addEventListener("click", () => {
|
|
const isHidden = sidebar.classList.toggle("hidden");
|
|
resizeHandle.classList.toggle("hidden", isHidden);
|
|
toggleBtn.classList.toggle("active", isHidden);
|
|
localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mobile sidebar
|
|
// ---------------------------------------------------------------------------
|
|
export function initMobile() {
|
|
const hamburger = document.getElementById("hamburger-btn");
|
|
const overlay = document.getElementById("sidebar-overlay");
|
|
const sidebar = document.getElementById("sidebar");
|
|
|
|
hamburger.addEventListener("click", () => {
|
|
sidebar.classList.toggle("mobile-open");
|
|
overlay.classList.toggle("active");
|
|
});
|
|
|
|
overlay.addEventListener("click", () => {
|
|
sidebar.classList.remove("mobile-open");
|
|
overlay.classList.remove("active");
|
|
});
|
|
}
|
|
|
|
function closeMobileSidebar() {
|
|
const sidebar = document.getElementById("sidebar");
|
|
const overlay = document.getElementById("sidebar-overlay");
|
|
if (sidebar) sidebar.classList.remove("mobile-open");
|
|
if (overlay) overlay.classList.remove("active");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resizable sidebar (horizontal)
|
|
// ---------------------------------------------------------------------------
|
|
export function initSidebarResize() {
|
|
const handle = document.getElementById("sidebar-resize-handle");
|
|
const sidebar = document.getElementById("sidebar");
|
|
if (!handle || !sidebar) return;
|
|
|
|
// Restore saved width
|
|
const savedWidth = localStorage.getItem("obsigate-sidebar-width");
|
|
if (savedWidth) {
|
|
sidebar.style.width = savedWidth + "px";
|
|
}
|
|
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
|
|
function onMouseMove(e) {
|
|
const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX)));
|
|
sidebar.style.width = newWidth + "px";
|
|
}
|
|
|
|
function onMouseUp() {
|
|
document.body.classList.remove("resizing");
|
|
handle.classList.remove("active");
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width));
|
|
}
|
|
|
|
handle.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
startX = e.clientX;
|
|
startWidth = sidebar.getBoundingClientRect().width;
|
|
document.body.classList.add("resizing");
|
|
handle.classList.add("active");
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resizable tag section (vertical)
|
|
// ---------------------------------------------------------------------------
|
|
export function initTagResize() {
|
|
const handle = document.getElementById("tag-resize-handle");
|
|
const tagSection = document.getElementById("tag-cloud-section");
|
|
if (!handle || !tagSection) return;
|
|
|
|
// Restore saved height
|
|
const savedHeight = localStorage.getItem("obsigate-tag-height");
|
|
if (savedHeight) {
|
|
tagSection.style.height = savedHeight + "px";
|
|
}
|
|
|
|
let startY = 0;
|
|
let startHeight = 0;
|
|
|
|
function onMouseMove(e) {
|
|
// Dragging up increases height, dragging down decreases
|
|
const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY)));
|
|
tagSection.style.height = newHeight + "px";
|
|
}
|
|
|
|
function onMouseUp() {
|
|
document.body.classList.remove("resizing-v");
|
|
handle.classList.remove("active");
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height));
|
|
}
|
|
|
|
handle.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
startY = e.clientY;
|
|
4
|
|
|
|
... [OUTPUT TRUNCATED - 30698 chars omitted out of 80698 total] ...
|
|
|
|
<div class="modal-warning">
|
|
<i data-lucide="alert-triangle" class="icon"></i>
|
|
<div>
|
|
<strong>Attention !</strong> Cette action est irréversible.
|
|
</div>
|
|
</div>
|
|
<div class="modal-form-group">
|
|
<label class="modal-label">Fichier à supprimer:</label>
|
|
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
|
</div>
|
|
</div>
|
|
<div class="obsigate-modal-footer">
|
|
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
|
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
|
</div>
|
|
`;
|
|
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
setTimeout(() => overlay.classList.add('active'), 10);
|
|
safeCreateIcons();
|
|
|
|
const confirmBtn = modal.querySelector('#del-confirm-btn');
|
|
const cancelBtn = modal.querySelector('#del-cancel-btn');
|
|
|
|
const deleteFile = async () => {
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.textContent = 'Suppression...';
|
|
|
|
try {
|
|
await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
showToast('Fichier supprimé', 'success');
|
|
this._closeModal(overlay);
|
|
await refreshSidebarTreePreservingState();
|
|
|
|
if (state.currentVault === vault && state.currentPath === path) {
|
|
showWelcome();
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message || 'Erreur lors de la suppression', 'error');
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.textContent = 'Supprimer définitivement';
|
|
}
|
|
};
|
|
|
|
confirmBtn.addEventListener('click', deleteFile);
|
|
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
|
|
},
|
|
|
|
_createModalOverlay() {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'obsigate-modal-overlay';
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) {
|
|
this._closeModal(overlay);
|
|
}
|
|
});
|
|
return overlay;
|
|
},
|
|
|
|
_closeModal(overlay) {
|
|
overlay.classList.remove('active');
|
|
setTimeout(() => overlay.remove(), 200);
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Find in Page Manager
|
|
// ---------------------------------------------------------------------------
|
|
export const FindInPageManager = {
|
|
isOpen: false,
|
|
searchTerm: "",
|
|
matches: [],
|
|
currentIndex: -1,
|
|
options: {
|
|
caseSensitive: false,
|
|
wholeWord: false,
|
|
useRegex: false,
|
|
},
|
|
debounceTimer: null,
|
|
previousFocus: null,
|
|
|
|
init() {
|
|
const bar = document.getElementById("find-in-page-bar");
|
|
const input = document.getElementById("find-input");
|
|
const prevBtn = document.getElementById("find-prev");
|
|
const nextBtn = document.getElementById("find-next");
|
|
const closeBtn = document.getElementById("find-close");
|
|
const caseSensitiveBtn = document.getElementById("find-case-sensitive");
|
|
const wholeWordBtn = document.getElementById("find-whole-word");
|
|
const regexBtn = document.getElementById("find-regex");
|
|
|
|
if (!bar || !input) return;
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener("keydown", (e) => {
|
|
// Ctrl+F or Cmd+F to open
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
|
|
e.preventDefault();
|
|
this.open();
|
|
}
|
|
// Escape to close
|
|
if (e.key === "Escape" && this.isOpen) {
|
|
e.preventDefault();
|
|
this.close();
|
|
}
|
|
// Enter to go to next
|
|
if (e.key === "Enter" && this.isOpen && document.activeElement === input) {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
this.goToPrevious();
|
|
} else {
|
|
this.goToNext();
|
|
}
|
|
}
|
|
// F3 for next/previous
|
|
if (e.key === "F3" && this.isOpen) {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
this.goToPrevious();
|
|
} else {
|
|
this.goToNext();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Input event with debounce
|
|
input.addEventListener("input", (e) => {
|
|
clearTimeout(this.debounceTimer);
|
|
this.debounceTimer = setTimeout(() => {
|
|
this.search(e.target.value);
|
|
}, 250);
|
|
});
|
|
|
|
// Navigation buttons
|
|
prevBtn.addEventListener("click", () => this.goToPrevious());
|
|
nextBtn.addEventListener("click", () => this.goToNext());
|
|
|
|
// Close button
|
|
closeBtn.addEventListener("click", () => this.close());
|
|
|
|
// Option toggles
|
|
caseSensitiveBtn.addEventListener("click", () => {
|
|
this.options.caseSensitive = !this.options.caseSensitive;
|
|
caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
|
|
this.saveState();
|
|
if (this.searchTerm) this.search(this.searchTerm);
|
|
});
|
|
|
|
wholeWordBtn.addEventListener("click", () => {
|
|
this.options.wholeWord = !this.options.wholeWord;
|
|
wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
|
|
this.saveState();
|
|
if (this.searchTerm) this.search(this.searchTerm);
|
|
});
|
|
|
|
regexBtn.addEventListener("click", () => {
|
|
this.options.useRegex = !this.options.useRegex;
|
|
regexBtn.setAttribute("aria-pressed", this.options.useRegex);
|
|
this.saveState();
|
|
if (this.searchTerm) this.search(this.searchTerm);
|
|
});
|
|
|
|
// Load saved state
|
|
this.loadState();
|
|
},
|
|
|
|
open() {
|
|
const bar = document.getElementById("find-in-page-bar");
|
|
const input = document.getElementById("find-input");
|
|
if (!bar || !input) return;
|
|
|
|
this.previousFocus = document.activeElement;
|
|
this.isOpen = true;
|
|
bar.hidden = false;
|
|
input.focus();
|
|
input.select();
|
|
safeCreateIcons();
|
|
},
|
|
|
|
close() {
|
|
const bar = document.getElementById("find-in-page-bar");
|
|
if (!bar) return;
|
|
|
|
this.isOpen = false;
|
|
bar.hidden = true;
|
|
this.clearHighlights();
|
|
this.matches = [];
|
|
this.currentIndex = -1;
|
|
this.searchTerm = "";
|
|
|
|
// Restore previous focus
|
|
if (this.previousFocus && this.previousFocus.focus) {
|
|
this.previousFocus.focus();
|
|
}
|
|
},
|
|
|
|
search(term) {
|
|
this.searchTerm = term;
|
|
this.clearHighlights();
|
|
this.hideError();
|
|
|
|
if (!term || term.trim().length === 0) {
|
|
this.updateCounter();
|
|
this.updateNavButtons();
|
|
return;
|
|
}
|
|
|
|
const contentArea = document.querySelector(".md-content");
|
|
if (!contentArea) {
|
|
this.updateCounter();
|
|
this.updateNavButtons();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const regex = this.createRegex(term);
|
|
this.matches = [];
|
|
this.findMatches(contentArea, regex);
|
|
this.currentIndex = this.matches.length > 0 ? 0 : -1;
|
|
this.highlightMatches();
|
|
this.updateCounter();
|
|
this.updateNavButtons();
|
|
|
|
if (this.matches.length > 0) {
|
|
this.scrollToMatch(0);
|
|
}
|
|
} catch (err) {
|
|
this.showError(err.message);
|
|
this.matches = [];
|
|
this.currentIndex = -1;
|
|
this.updateCounter();
|
|
this.updateNavButtons();
|
|
}
|
|
},
|
|
|
|
createRegex(term) {
|
|
let pattern = term;
|
|
|
|
if (!this.options.useRegex) {
|
|
// Escape special regex characters
|
|
pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
if (this.options.wholeWord) {
|
|
pattern = "\\b" + pattern + "\\b";
|
|
}
|
|
|
|
const flags = this.options.caseSensitive ? "g" : "gi";
|
|
return new RegExp(pattern, flags);
|
|
},
|
|
|
|
findMatches(container, regex) {
|
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
acceptNode: (node) => {
|
|
// Skip code blocks, scripts, styles
|
|
const parent = node.parentElement;
|
|
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
const tagName = parent.tagName.toLowerCase();
|
|
if (["code", "pre", "script", "style"].includes(tagName)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
// Skip empty text nodes
|
|
if (!node.textContent || node.textContent.trim().length === 0) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
},
|
|
});
|
|
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
const text = node.textContent;
|
|
let match;
|
|
regex.lastIndex = 0; // Reset regex
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
this.matches.push({
|
|
node: node,
|
|
index: match.index,
|
|
length: match[0].length,
|
|
text: match[0],
|
|
});
|
|
|
|
// Prevent infinite loop with zero-width matches
|
|
if (match.index === regex.lastIndex) {
|
|
regex.lastIndex++;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
highlightMatches() {
|
|
const matchesByNode = new Map();
|
|
|
|
this.matches.forEach((match, idx) => {
|
|
if (!matchesByNode.has(match.node)) {
|
|
matchesByNode.set(match.node, []);
|
|
}
|
|
matchesByNode.get(match.node).push({ match, idx });
|
|
});
|
|
|
|
matchesByNode.forEach((entries, node) => {
|
|
if (!node || !node.parentNode) return;
|
|
|
|
const text = node.textContent || "";
|
|
let cursor = 0;
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
entries.sort((a, b) => a.match.index - b.match.index);
|
|
|
|
entries.forEach(({ match, idx }) => {
|
|
if (match.index > cursor) {
|
|
fragment.appendChild(document.createTextNode(text.substring(cursor, match.index)));
|
|
}
|
|
|
|
const matchText = text.substring(match.index, match.index + match.length);
|
|
const mark = document.createElement("mark");
|
|
mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight";
|
|
mark.textContent = matchText;
|
|
mark.setAttribute("data-find-index", idx);
|
|
fragment.appendChild(mark);
|
|
|
|
match.element = mark;
|
|
cursor = match.index + match.length;
|
|
});
|
|
|
|
if (cursor < text.length) {
|
|
fragment.appendChild(document.createTextNode(text.substring(cursor)));
|
|
}
|
|
|
|
node.parentNode.replaceChild(fragment, node);
|
|
});
|
|
},
|
|
|
|
clearHighlights() {
|
|
const contentArea = document.querySelector(".md-content");
|
|
if (!contentArea) return;
|
|
|
|
const marks = contentArea.querySelectorAll("mark.find-highlight");
|
|
marks.forEach((mark) => {
|
|
if (!mark.parentNode) return;
|
|
const text = mark.textContent;
|
|
const textNode = document.createTextNode(text);
|
|
mark.parentNode.replaceChild(textNode, mark);
|
|
});
|
|
|
|
// Normalize text nodes to merge adjacent text nodes
|
|
contentArea.normalize();
|
|
},
|
|
|
|
goToNext() {
|
|
if (this.matches.length === 0) return;
|
|
|
|
// Remove active class from current
|
|
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
|
|
this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
|
|
}
|
|
|
|
// Move to next (with wrapping)
|
|
this.currentIndex = (this.currentIndex + 1) % this.matches.length;
|
|
|
|
// Add active class to new current
|
|
if (this.matches[this.currentIndex].element) {
|
|
this.matches[this.currentIndex].element.classList.add("find-highlight-active");
|
|
}
|
|
|
|
this.scrollToMatch(this.currentIndex);
|
|
this.updateCounter();
|
|
},
|
|
|
|
goToPrevious() {
|
|
if (this.matches.length === 0) return;
|
|
|
|
// Remove active class from current
|
|
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
|
|
this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
|
|
}
|
|
|
|
// Move to previous (with wrapping)
|
|
this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1;
|
|
|
|
// Add active class to new current
|
|
if (this.matches[this.currentIndex].element) {
|
|
this.matches[this.currentIndex].element.classList.add("find-highlight-active");
|
|
}
|
|
|
|
this.scrollToMatch(this.currentIndex);
|
|
this.updateCounter();
|
|
},
|
|
|
|
scrollToMatch(index) {
|
|
if (index < 0 || index >= this.matches.length) return;
|
|
|
|
const match = this.matches[index];
|
|
if (!match.element) return;
|
|
|
|
const contentArea = document.getElementById("content-area");
|
|
if (!contentArea) {
|
|
match.element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
return;
|
|
}
|
|
|
|
// Calculate position with offset for header
|
|
const elementTop = match.element.offsetTop;
|
|
const offset = 100; // Offset for header
|
|
|
|
contentArea.scrollTo({
|
|
top: elementTop - offset,
|
|
behavior: "smooth",
|
|
});
|
|
},
|
|
|
|
updateCounter() {
|
|
const counter = document.getElementById("find-counter");
|
|
if (!counter) return;
|
|
|
|
const count = this.matches.length;
|
|
if (count === 0) {
|
|
counter.textContent = "0 occurrence";
|
|
} else if (count === 1) {
|
|
counter.textContent = "1 occurrence";
|
|
} else {
|
|
counter.textContent = `${count} occurrences`;
|
|
}
|
|
},
|
|
|
|
updateNavButtons() {
|
|
const prevBtn = document.getElementById("find-prev");
|
|
const nextBtn = document.getElementById("find-next");
|
|
if (!prevBtn || !nextBtn) return;
|
|
|
|
const hasMatches = this.matches.length > 0;
|
|
prevBtn.disabled = !hasMatches;
|
|
nextBtn.disabled = !hasMatches;
|
|
},
|
|
|
|
showError(message) {
|
|
const errorEl = document.getElementById("find-error");
|
|
if (!errorEl) return;
|
|
|
|
errorEl.textContent = message;
|
|
errorEl.hidden = false;
|
|
},
|
|
|
|
hideError() {
|
|
const errorEl = document.getElementById("find-error");
|
|
if (!errorEl) return;
|
|
|
|
errorEl.hidden = true;
|
|
},
|
|
|
|
saveState() {
|
|
try {
|
|
const state = {
|
|
options: this.options,
|
|
};
|
|
localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state));
|
|
} catch (e) {
|
|
// Ignore localStorage errors
|
|
}
|
|
},
|
|
|
|
loadState() {
|
|
try {
|
|
const saved = localStorage.getItem("obsigate-find-in-page-state");
|
|
if (saved) {
|
|
const state = JSON.parse(saved);
|
|
if (state.options) {
|
|
this.options = { ...this.options, ...state.options };
|
|
|
|
// Update button states
|
|
const caseSensitiveBtn = document.getElementById("find-case-sensitive");
|
|
const wholeWordBtn = document.getElementById("find-whole-word");
|
|
const regexBtn = document.getElementById("find-regex");
|
|
|
|
if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
|
|
if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
|
|
if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore localStorage errors
|
|
}
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab Manager
|
|
// ---------------------------------------------------------------------------
|
|
export const TabManager = {
|
|
_tabs: [],
|
|
_activeTabId: null,
|
|
_previewTabId: null, // single-click preview tab (temporary, replaced on next preview)
|
|
_tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } }
|
|
_tabBar: null,
|
|
_tabList: null,
|
|
_dirtyTabs: new Set(),
|
|
|
|
init() {
|
|
this._tabBar = document.getElementById("tab-bar");
|
|
this._tabList = document.getElementById("tab-list");
|
|
},
|
|
|
|
/** Open a file as a preview tab (single-click).
|
|
* Replaces any existing preview tab. If the file is already
|
|
* open as a persistent tab, just activates it. */
|
|
async openPreview(vault, path) {
|
|
const tabId = `${vault}::${path}`;
|
|
|
|
// If already open as persistent tab, just activate it
|
|
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
|
|
if (existing) {
|
|
this.activate(tabId);
|
|
return;
|
|
}
|
|
|
|
// Close existing preview tab
|
|
if (this._previewTabId && this._previewTabId !== tabId) {
|
|
this.close(this._previewTabId);
|
|
}
|
|
|
|
// If already open as preview, just focus it
|
|
const previewExisting = this._tabs.find(t => t.id === tabId && t.preview);
|
|
if (previewExisting) {
|
|
this.activate(tabId);
|
|
return;
|
|
}
|
|
|
|
// Create preview tab
|
|
const name = path.split("/").pop().replace(/\.md$/i, "");
|
|
const icon = getFileIcon(name + ".md");
|
|
|
|
this._tabs.push({ id: tabId, vault, path, name, icon, preview: true });
|
|
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
|
|
this._previewTabId = tabId;
|
|
|
|
this._renderTabs();
|
|
this.activate(tabId);
|
|
},
|
|
|
|
/** Convert a preview tab to a persistent tab (double-click).
|
|
* If already persistent, opens a new duplicate (same file, different tab). */
|
|
async openPersistent(vault, path) {
|
|
const tabId = `${vault}::${path}`;
|
|
|
|
// If it's already a preview tab, convert it to persistent
|
|
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
|
|
if (previewTab) {
|
|
previewTab.preview = false;
|
|
if (this._previewTabId === tabId) {
|
|
this._previewTabId = null;
|
|
}
|
|
this._renderTabs();
|
|
this.activate(tabId);
|
|
return;
|
|
}
|
|
|
|
// If already persistent, just focus it
|
|
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
|
|
if (existing) {
|
|
this.activate(tabId);
|
|
return;
|
|
}
|
|
|
|
// Create a new persistent tab
|
|
this.open(vault, path);
|
|
},
|
|
|
|
/** Open a file in a tab (or focus existing) */
|
|
async open(vault, path, options = {}) {
|
|
const tabId = `${vault}::${path}`;
|
|
|
|
// If already open, just focus it
|
|
const existing = this._tabs.find(t => t.id === tabId);
|
|
if (existing) {
|
|
// Convert preview to persistent if needed
|
|
if (existing.preview) {
|
|
existing.preview = false;
|
|
if (this._previewTabId === tabId) this._previewTabId = null;
|
|
this._renderTabs();
|
|
}
|
|
this.activate(tabId);
|
|
return;
|
|
}
|
|
|
|
// Create new tab
|
|
const name = path.split("/").pop().replace(/\.md$/i, "");
|
|
const icon = getFileIcon(name + ".md");
|
|
|
|
this._tabs.push({ id: tabId, vault, path, name, icon });
|
|
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
|
|
|
|
this._renderTabs();
|
|
this.activate(tabId);
|
|
},
|
|
|
|
/** Activate a specific tab */
|
|
async activate(tabId) {
|
|
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
|
|
|
// Save current tab state
|
|
if (this._activeTabId && this._tabCache[this._activeTabId]) {
|
|
this._saveCurrentTabState();
|
|
}
|
|
|
|
this._activeTabId = tabId;
|
|
this._renderTabs();
|
|
|
|
// Load tab content
|
|
const cache = this._tabCache[tabId];
|
|
if (!cache) return;
|
|
|
|
// Update global state
|
|
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) {
|
|
const idx = this._tabs.findIndex(t => t.id === tabId);
|
|
if (idx === -1) return;
|
|
|
|
this._tabs.splice(idx, 1);
|
|
delete this._tabCache[tabId];
|
|
this._dirtyTabs.delete(tabId);
|
|
|
|
if (this._tabs.length === 0) {
|
|
this._activeTabId = null;
|
|
this._showDashboard();
|
|
this._tabBar.hidden = true;
|
|
} else if (this._activeTabId === tabId) {
|
|
// Activate adjacent tab
|
|
const newIdx = Math.min(idx, this._tabs.length - 1);
|
|
this.activate(this._tabs[newIdx].id);
|
|
}
|
|
|
|
this._renderTabs();
|
|
},
|
|
|
|
/** Close all tabs */
|
|
closeAll() {
|
|
this._tabs = [];
|
|
this._tabCache = {};
|
|
this._dirtyTabs.clear();
|
|
this._activeTabId = null;
|
|
this._showDashboard();
|
|
this._tabBar.hidden = true;
|
|
},
|
|
|
|
/** Close tabs to the right */
|
|
closeRight(tabId) {
|
|
const idx = this._tabs.findIndex(t => t.id === tabId);
|
|
if (idx === -1) return;
|
|
const toClose = this._tabs.slice(idx + 1);
|
|
for (const tab of toClose) {
|
|
delete this._tabCache[tab.id];
|
|
this._dirtyTabs.delete(tab.id);
|
|
}
|
|
this._tabs = this._tabs.slice(0, idx + 1);
|
|
if (!this._tabs.find(t => t.id === this._activeTabId)) {
|
|
this.activate(tabId);
|
|
}
|
|
this._renderTabs();
|
|
},
|
|
|
|
/** Close other tabs */
|
|
closeOthers(tabId) {
|
|
const tab = this._tabs.find(t => t.id === tabId);
|
|
if (!tab) return;
|
|
for (const t of this._tabs) {
|
|
if (t.id !== tabId) {
|
|
delete this._tabCache[t.id];
|
|
this._dirtyTabs.delete(t.id);
|
|
}
|
|
}
|
|
this._tabs = [tab];
|
|
this.activate(tabId);
|
|
this._renderTabs();
|
|
},
|
|
|
|
/** Reorder tabs by drag and drop */
|
|
moveTab(fromIdx, toIdx) {
|
|
if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
|
|
const tab = this._tabs.splice(fromIdx, 1)[0];
|
|
this._tabs.splice(toIdx, 0, tab);
|
|
this._renderTabs();
|
|
},
|
|
|
|
/** Save current tab state before switching */
|
|
_saveCurrentTabState() {
|
|
const cache = this._tabCache[this._activeTabId];
|
|
if (!cache) return;
|
|
|
|
const area = document.getElementById("content-area");
|
|
const rendered = document.getElementById("file-rendered-content");
|
|
|
|
cache.scrollTop = area.scrollTop;
|
|
cache.sourceView = rendered ? rendered.style.display === "none" : false;
|
|
},
|
|
|
|
/** Restore tab content from cache */
|
|
_restoreTabContent(cache, area) {
|
|
renderFile(cache.data);
|
|
if (cache.sourceView) {
|
|
this._restoreSourceView(cache, area);
|
|
}
|
|
if (cache.scrollTop) {
|
|
area.scrollTop = cache.scrollTop;
|
|
}
|
|
},
|
|
|
|
async _toggleSourceView(cache, area) {
|
|
const rendered = document.getElementById("file-rendered-content");
|
|
const raw = document.getElementById("file-raw-content");
|
|
if (!rendered || !raw) return;
|
|
|
|
if (!cache.rawSource) {
|
|
const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`);
|
|
cache.rawSource = rawData.raw;
|
|
}
|
|
raw.textContent = cache.rawSource;
|
|
rendered.style.display = "none";
|
|
raw.style.display = "block";
|
|
},
|
|
|
|
_restoreSourceView(cache, area) {
|
|
requestAnimationFrame(() => {
|
|
const rendered = document.getElementById("file-rendered-content");
|
|
const raw = document.getElementById("file-raw-content");
|
|
if (rendered && raw && cache.rawSource) {
|
|
raw.textContent = cache.rawSource;
|
|
rendered.style.display = "none";
|
|
raw.style.display = "block";
|
|
}
|
|
});
|
|
},
|
|
|
|
_showDashboard() {
|
|
const area = document.getElementById("content-area");
|
|
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
|
|
let dashboard = document.getElementById("dashboard-home");
|
|
if (!dashboard) {
|
|
// Dashboard was destroyed — rebuild via showWelcome
|
|
area.innerHTML = "";
|
|
showWelcome();
|
|
return;
|
|
}
|
|
area.innerHTML = "";
|
|
dashboard.style.display = "";
|
|
area.appendChild(dashboard);
|
|
// Refresh widgets after restoring
|
|
if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load();
|
|
if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load();
|
|
if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(state.selectedContextVault);
|
|
if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(state.selectedContextVault);
|
|
if (history.pushState) {
|
|
history.pushState(null, "", "#");
|
|
}
|
|
},
|
|
|
|
/** Render the tab bar */
|