ES module imports are read-only live bindings — can't reassign
imported let/const variables. Replace individual 'export let' with
single 'export const state = {...}' mutable object.
All modules updated: import { state } from './state.js'
All state access changed to state.xxx pattern.
Fixes cascade of 'Assignment to constant variable' errors.
2001 lines
79 KiB
JavaScript
2001 lines
79 KiB
JavaScript
1|/* ObsiGate — UI: theme, sidebar, context menus, tabs, toast, find-in-page */
|
||
import { state } from './state.js';
|
||
3|import { openFile } from './viewer.js';
|
||
4|import { safeCreateIcons } from './utils.js';
|
||
5|
|
||
6|// ---------------------------------------------------------------------------
|
||
7|// Right Sidebar Manager
|
||
8|// ---------------------------------------------------------------------------
|
||
9|
|
||
10|export const RightSidebarManager = {
|
||
11| init() {
|
||
12| this.loadState();
|
||
13| this.initToggle();
|
||
14| this.initResize();
|
||
15| },
|
||
16|
|
||
17| loadState() {
|
||
18| const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
|
||
19| const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
|
||
20|
|
||
21| if (savedVisible !== null) {
|
||
22| state.rightSidebarVisible = savedVisible === "true";
|
||
23| }
|
||
24|
|
||
25| if (savedWidth) {
|
||
26| state.rightSidebarWidth = parseInt(savedWidth) || 280;
|
||
27| }
|
||
28|
|
||
29| this.applyState();
|
||
30| },
|
||
31|
|
||
32| applyState() {
|
||
33| const sidebar = document.getElementById("right-sidebar");
|
||
34| const handle = document.getElementById("right-sidebar-resize-handle");
|
||
35| const tocBtn = document.getElementById("toc-toggle-btn");
|
||
36| const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn");
|
||
37|
|
||
38| if (!sidebar) return;
|
||
39|
|
||
40| if (state.rightSidebarVisible) {
|
||
41| sidebar.classList.remove("hidden");
|
||
42| sidebar.style.width = `${state.rightSidebarWidth}px`;
|
||
43| if (handle) handle.classList.remove("hidden");
|
||
44| if (tocBtn) {
|
||
45| tocBtn.classList.add("active");
|
||
46| tocBtn.title = "Masquer le sommaire";
|
||
47| }
|
||
48| if (headerToggleBtn) {
|
||
49| headerToggleBtn.title = "Masquer le panneau";
|
||
50| headerToggleBtn.setAttribute("aria-label", "Masquer le panneau");
|
||
51| }
|
||
52| } else {
|
||
53| sidebar.classList.add("hidden");
|
||
54| if (handle) handle.classList.add("hidden");
|
||
55| if (tocBtn) {
|
||
56| tocBtn.classList.remove("active");
|
||
57| tocBtn.title = "Afficher le sommaire";
|
||
58| }
|
||
59| if (headerToggleBtn) {
|
||
60| headerToggleBtn.title = "Afficher le panneau";
|
||
61| headerToggleBtn.setAttribute("aria-label", "Afficher le panneau");
|
||
62| }
|
||
63| }
|
||
64|
|
||
65| // Update icons
|
||
66| safeCreateIcons();
|
||
67| },
|
||
68|
|
||
69| toggle() {
|
||
70| state.rightSidebarVisible = !state.rightSidebarVisible;
|
||
71| localStorage.setItem("obsigate-right-sidebar-visible", state.rightSidebarVisible);
|
||
72| this.applyState();
|
||
73| },
|
||
74|
|
||
75| initToggle() {
|
||
76| const toggleBtn = document.getElementById("right-sidebar-toggle-btn");
|
||
77| if (toggleBtn) {
|
||
78| toggleBtn.addEventListener("click", () => this.toggle());
|
||
79| }
|
||
80| },
|
||
81|
|
||
82| initResize() {
|
||
83| const handle = document.getElementById("right-sidebar-resize-handle");
|
||
84| const sidebar = document.getElementById("right-sidebar");
|
||
85|
|
||
86| if (!handle || !sidebar) return;
|
||
87|
|
||
88| let isResizing = false;
|
||
89| let startX = 0;
|
||
90| let startWidth = 0;
|
||
91|
|
||
92| const onMouseDown = (e) => {
|
||
93| isResizing = true;
|
||
94| startX = e.clientX;
|
||
95| startWidth = sidebar.offsetWidth;
|
||
96| handle.classList.add("active");
|
||
97| document.body.style.cursor = "ew-resize";
|
||
98| document.body.style.userSelect = "none";
|
||
99| };
|
||
100|
|
||
101| const onMouseMove = (e) => {
|
||
102| if (!isResizing) return;
|
||
103|
|
||
104| const delta = startX - e.clientX;
|
||
105| let newWidth = startWidth + delta;
|
||
106|
|
||
107| // Constrain width
|
||
108| newWidth = Math.max(200, Math.min(400, newWidth));
|
||
109|
|
||
110| sidebar.style.width = `${newWidth}px`;
|
||
111| state.rightSidebarWidth = newWidth;
|
||
112| };
|
||
113|
|
||
114| const onMouseUp = () => {
|
||
115| if (!isResizing) return;
|
||
116|
|
||
117| isResizing = false;
|
||
118| handle.classList.remove("active");
|
||
119| document.body.style.cursor = "";
|
||
120| document.body.style.userSelect = "";
|
||
121|
|
||
122| localStorage.setItem("obsigate-right-sidebar-width", state.rightSidebarWidth);
|
||
123| };
|
||
124|
|
||
125| handle.addEventListener("mousedown", onMouseDown);
|
||
126| document.addEventListener("mousemove", onMouseMove);
|
||
127| document.addEventListener("mouseup", onMouseUp);
|
||
128| },
|
||
129|};
|
||
130|
|
||
131|// ---------------------------------------------------------------------------
|
||
132|// Theme
|
||
133|// ---------------------------------------------------------------------------
|
||
134|export function initTheme() {
|
||
135| const saved = localStorage.getItem("obsigate-theme") || "dark";
|
||
136| applyTheme(saved);
|
||
137|}
|
||
138|
|
||
139|export function applyTheme(theme) {
|
||
140| document.documentElement.setAttribute("data-theme", theme);
|
||
141| localStorage.setItem("obsigate-theme", theme);
|
||
142|
|
||
143| // Update theme button icon and label
|
||
144| const themeBtn = document.getElementById("theme-toggle");
|
||
145| const themeLabel = document.getElementById("theme-label");
|
||
146| if (themeBtn && themeLabel) {
|
||
147| const icon = themeBtn.querySelector("i");
|
||
148| if (icon) {
|
||
149| icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
|
||
150| }
|
||
151| themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair";
|
||
152| safeCreateIcons();
|
||
153| }
|
||
154|
|
||
155| // Swap highlight.js theme
|
||
156| const darkSheet = document.getElementById("hljs-theme-dark");
|
||
157| const lightSheet = document.getElementById("hljs-theme-light");
|
||
158| if (darkSheet && lightSheet) {
|
||
159| darkSheet.disabled = theme !== "dark";
|
||
160| lightSheet.disabled = theme !== "light";
|
||
161| }
|
||
162|}
|
||
163|
|
||
164|export function toggleTheme() {
|
||
165| const current = document.documentElement.getAttribute("data-theme");
|
||
166| applyTheme(current === "dark" ? "light" : "dark");
|
||
167|}
|
||
168|
|
||
169|export function initHeaderMenu() {
|
||
170| const menuBtn = document.getElementById("header-menu-btn");
|
||
171| const menuDropdown = document.getElementById("header-menu-dropdown");
|
||
172|
|
||
173| if (!menuBtn || !menuDropdown) return;
|
||
174|
|
||
175| menuBtn.addEventListener("click", (e) => {
|
||
176| e.stopPropagation();
|
||
177| menuBtn.classList.toggle("active");
|
||
178| menuDropdown.classList.toggle("active");
|
||
179| });
|
||
180|
|
||
181| // Close menu when clicking outside
|
||
182| document.addEventListener("click", (e) => {
|
||
183| if (!menuDropdown.contains(e.target) && e.target !== menuBtn) {
|
||
184| menuBtn.classList.remove("active");
|
||
185| menuDropdown.classList.remove("active");
|
||
186| }
|
||
187| });
|
||
188|
|
||
189| // Prevent menu from closing when clicking inside
|
||
190| menuDropdown.addEventListener("click", (e) => {
|
||
191| e.stopPropagation();
|
||
192| });
|
||
193|}
|
||
194|
|
||
195|function closeHeaderMenu() {
|
||
196| const menuBtn = document.getElementById("header-menu-btn");
|
||
197| const menuDropdown = document.getElementById("header-menu-dropdown");
|
||
198| if (!menuBtn || !menuDropdown) return;
|
||
199| menuBtn.classList.remove("active");
|
||
200| menuDropdown.classList.remove("active");
|
||
201|}
|
||
202|
|
||
203|// ---------------------------------------------------------------------------
|
||
204|// Custom Dropdowns
|
||
205|// ---------------------------------------------------------------------------
|
||
206|export function initCustomDropdowns() {
|
||
207| document.querySelectorAll(".custom-dropdown").forEach((dropdown) => {
|
||
208| const trigger = dropdown.querySelector(".custom-dropdown-trigger");
|
||
209| const options = dropdown.querySelectorAll(".custom-dropdown-option");
|
||
210| const hiddenInput = dropdown.querySelector('input[type="hidden"]');
|
||
211| const selectedText = dropdown.querySelector(".custom-dropdown-selected");
|
||
212| const menu = dropdown.querySelector(".custom-dropdown-menu");
|
||
213|
|
||
214| if (!trigger) return;
|
||
215|
|
||
216| // Toggle dropdown
|
||
217| trigger.addEventListener("click", (e) => {
|
||
218| e.stopPropagation();
|
||
219| const isOpen = dropdown.classList.contains("open");
|
||
220|
|
||
221| // Close all other dropdowns
|
||
222| document.querySelectorAll(".custom-dropdown.open").forEach((d) => {
|
||
223| if (d !== dropdown) d.classList.remove("open");
|
||
224| });
|
||
225|
|
||
226| dropdown.classList.toggle("open", !isOpen);
|
||
227| trigger.setAttribute("aria-expanded", !isOpen);
|
||
228|
|
||
229| // Position fixed menu for sidebar dropdowns
|
||
230| if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) {
|
||
231| const rect = trigger.getBoundingClientRect();
|
||
232| menu.style.top = `${rect.bottom + 4}px`;
|
||
233| menu.style.left = `${rect.left}px`;
|
||
234| menu.style.width = `${rect.width}px`;
|
||
235| }
|
||
236| });
|
||
237|
|
||
238| // Handle option selection
|
||
239| options.forEach((option) => {
|
||
240| option.addEventListener("click", (e) => {
|
||
241| e.stopPropagation();
|
||
242| const value = option.getAttribute("data-value");
|
||
243| const text = option.textContent;
|
||
244|
|
||
245| // Update hidden input
|
||
246| if (hiddenInput) {
|
||
247| hiddenInput.value = value;
|
||
248| // Trigger change event
|
||
249| hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||
250| }
|
||
251|
|
||
252| // Update selected text
|
||
253| if (selectedText) {
|
||
254| selectedText.textContent = text;
|
||
255| }
|
||
256|
|
||
257| // Update visual selection
|
||
258| options.forEach((opt) => opt.classList.remove("selected"));
|
||
259| option.classList.add("selected");
|
||
260|
|
||
261| // Close dropdown
|
||
262| dropdown.classList.remove("open");
|
||
263| trigger.setAttribute("aria-expanded", "false");
|
||
264| });
|
||
265| });
|
||
266| });
|
||
267|
|
||
268| // Close dropdowns when clicking outside
|
||
269| document.addEventListener("click", () => {
|
||
270| document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => {
|
||
271| dropdown.classList.remove("open");
|
||
272| const trigger = dropdown.querySelector(".custom-dropdown-trigger");
|
||
273| if (trigger) trigger.setAttribute("aria-expanded", "false");
|
||
274| });
|
||
275| });
|
||
276|}
|
||
277|
|
||
278|// Helper to populate custom dropdown options
|
||
279|function populateCustomDropdown(dropdownId, optionsList, defaultValue) {
|
||
280| const dropdown = document.getElementById(dropdownId);
|
||
281| if (!dropdown) return;
|
||
282|
|
||
283| const optionsContainer = dropdown.querySelector(".custom-dropdown-menu");
|
||
284| const hiddenInput = dropdown.querySelector('input[type="hidden"]');
|
||
285| const selectedText = dropdown.querySelector(".custom-dropdown-selected");
|
||
286|
|
||
287| if (!optionsContainer) return;
|
||
288|
|
||
289| // Clear existing options (keep the first one if it's the default)
|
||
290| optionsContainer.innerHTML = "";
|
||
291|
|
||
292| // Add new options
|
||
293| optionsList.forEach((opt) => {
|
||
294| const li = document.createElement("li");
|
||
295| li.className = "custom-dropdown-option";
|
||
296| li.setAttribute("role", "option");
|
||
297| li.setAttribute("data-value", opt.value);
|
||
298| li.textContent = opt.text;
|
||
299| if (opt.value === defaultValue) {
|
||
300| li.classList.add("selected");
|
||
301| if (selectedText) selectedText.textContent = opt.text;
|
||
302| if (hiddenInput) hiddenInput.value = opt.value;
|
||
303| }
|
||
304| optionsContainer.appendChild(li);
|
||
305| });
|
||
306|
|
||
307| // Re-initialize click handlers
|
||
308| optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => {
|
||
309| option.addEventListener("click", (e) => {
|
||
310| e.stopPropagation();
|
||
311| const value = option.getAttribute("data-value");
|
||
312| const text = option.textContent;
|
||
313|
|
||
314| if (hiddenInput) {
|
||
315| hiddenInput.value = value;
|
||
316| hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||
317| }
|
||
318|
|
||
319| if (selectedText) {
|
||
320| selectedText.textContent = text;
|
||
321| }
|
||
322|
|
||
323| optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected"));
|
||
324| option.classList.add("selected");
|
||
325|
|
||
326| dropdown.classList.remove("open");
|
||
327| const trigger = dropdown.querySelector(".custom-dropdown-trigger");
|
||
328| if (trigger) trigger.setAttribute("aria-expanded", "false");
|
||
329| });
|
||
330| });
|
||
331|}
|
||
332|
|
||
333|// ---------------------------------------------------------------------------
|
||
334|// Toast notifications
|
||
335|// ---------------------------------------------------------------------------
|
||
336|
|
||
337|/** Display a brief toast message at the bottom of the viewport. */
|
||
338|export function showToast(message, type) {
|
||
339| console.log("showToast called with:", message, type);
|
||
340| type = type || "info";
|
||
341| let container = document.getElementById("toast-container");
|
||
342| if (!container) {
|
||
343| container = document.createElement("div");
|
||
344| container.id = "toast-container";
|
||
345| container.className = "toast-container";
|
||
346| container.setAttribute("aria-live", "polite");
|
||
347| document.body.appendChild(container);
|
||
348| }
|
||
349| var toast = document.createElement("div");
|
||
350| toast.className = "toast toast-" + type;
|
||
351| toast.textContent = message;
|
||
352| container.appendChild(toast);
|
||
353| // Trigger entrance animation
|
||
354| requestAnimationFrame(function () {
|
||
355| toast.classList.add("show");
|
||
356| });
|
||
357| setTimeout(function () {
|
||
358| toast.classList.remove("show");
|
||
359| toast.addEventListener("transitionend", function () {
|
||
360| toast.remove();
|
||
361| });
|
||
362| }, 3500);
|
||
363|}
|
||
364|
|
||
365|// ---------------------------------------------------------------------------
|
||
366|// Sidebar toggle (desktop)
|
||
367|// ---------------------------------------------------------------------------
|
||
368|export function initSidebarToggle() {
|
||
369| const toggleBtn = document.getElementById("sidebar-toggle-btn");
|
||
370| const sidebar = document.getElementById("sidebar");
|
||
371| const resizeHandle = document.getElementById("sidebar-resize-handle");
|
||
372|
|
||
373| if (!toggleBtn || !sidebar || !resizeHandle) return;
|
||
374|
|
||
375| // Restore saved state
|
||
376| const savedState = localStorage.getItem("obsigate-sidebar-hidden");
|
||
377| if (savedState === "true") {
|
||
378| sidebar.classList.add("hidden");
|
||
379| resizeHandle.classList.add("hidden");
|
||
380| toggleBtn.classList.add("active");
|
||
381| }
|
||
382|
|
||
383| toggleBtn.addEventListener("click", () => {
|
||
384| const isHidden = sidebar.classList.toggle("hidden");
|
||
385| resizeHandle.classList.toggle("hidden", isHidden);
|
||
386| toggleBtn.classList.toggle("active", isHidden);
|
||
387| localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false");
|
||
388| });
|
||
389|}
|
||
390|
|
||
391|// ---------------------------------------------------------------------------
|
||
392|// Mobile sidebar
|
||
393|// ---------------------------------------------------------------------------
|
||
394|export function initMobile() {
|
||
395| const hamburger = document.getElementById("hamburger-btn");
|
||
396| const overlay = document.getElementById("sidebar-overlay");
|
||
397| const sidebar = document.getElementById("sidebar");
|
||
398|
|
||
399| hamburger.addEventListener("click", () => {
|
||
400| sidebar.classList.toggle("mobile-open");
|
||
401| overlay.classList.toggle("active");
|
||
402| });
|
||
403|
|
||
404| overlay.addEventListener("click", () => {
|
||
405| sidebar.classList.remove("mobile-open");
|
||
406| overlay.classList.remove("active");
|
||
407| });
|
||
408|}
|
||
409|
|
||
410|function closeMobileSidebar() {
|
||
411| const sidebar = document.getElementById("sidebar");
|
||
412| const overlay = document.getElementById("sidebar-overlay");
|
||
413| if (sidebar) sidebar.classList.remove("mobile-open");
|
||
414| if (overlay) overlay.classList.remove("active");
|
||
415|}
|
||
416|
|
||
417|// ---------------------------------------------------------------------------
|
||
418|// Resizable sidebar (horizontal)
|
||
419|// ---------------------------------------------------------------------------
|
||
420|export function initSidebarResize() {
|
||
421| const handle = document.getElementById("sidebar-resize-handle");
|
||
422| const sidebar = document.getElementById("sidebar");
|
||
423| if (!handle || !sidebar) return;
|
||
424|
|
||
425| // Restore saved width
|
||
426| const savedWidth = localStorage.getItem("obsigate-sidebar-width");
|
||
427| if (savedWidth) {
|
||
428| sidebar.style.width = savedWidth + "px";
|
||
429| }
|
||
430|
|
||
431| let startX = 0;
|
||
432| let startWidth = 0;
|
||
433|
|
||
434| function onMouseMove(e) {
|
||
435| const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX)));
|
||
436| sidebar.style.width = newWidth + "px";
|
||
437| }
|
||
438|
|
||
439| function onMouseUp() {
|
||
440| document.body.classList.remove("resizing");
|
||
441| handle.classList.remove("active");
|
||
442| document.removeEventListener("mousemove", onMouseMove);
|
||
443| document.removeEventListener("mouseup", onMouseUp);
|
||
444| localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width));
|
||
445| }
|
||
446|
|
||
447| handle.addEventListener("mousedown", (e) => {
|
||
448| e.preventDefault();
|
||
449| startX = e.clientX;
|
||
450| startWidth = sidebar.getBoundingClientRect().width;
|
||
451| document.body.classList.add("resizing");
|
||
452| handle.classList.add("active");
|
||
453| document.addEventListener("mousemove", onMouseMove);
|
||
454| document.addEventListener("mouseup", onMouseUp);
|
||
455| });
|
||
456|}
|
||
457|
|
||
458|// ---------------------------------------------------------------------------
|
||
459|// Resizable tag section (vertical)
|
||
460|// ---------------------------------------------------------------------------
|
||
461|export function initTagResize() {
|
||
462| const handle = document.getElementById("tag-resize-handle");
|
||
463| const tagSection = document.getElementById("tag-cloud-section");
|
||
464| if (!handle || !tagSection) return;
|
||
465|
|
||
466| // Restore saved height
|
||
467| const savedHeight = localStorage.getItem("obsigate-tag-height");
|
||
468| if (savedHeight) {
|
||
469| tagSection.style.height = savedHeight + "px";
|
||
470| }
|
||
471|
|
||
472| let startY = 0;
|
||
473| let startHeight = 0;
|
||
474|
|
||
475| function onMouseMove(e) {
|
||
476| // Dragging up increases height, dragging down decreases
|
||
477| const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY)));
|
||
478| tagSection.style.height = newHeight + "px";
|
||
479| }
|
||
480|
|
||
481| function onMouseUp() {
|
||
482| document.body.classList.remove("resizing-v");
|
||
483| handle.classList.remove("active");
|
||
484| document.removeEventListener("mousemove", onMouseMove);
|
||
485| document.removeEventListener("mouseup", onMouseUp);
|
||
486| localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height));
|
||
487| }
|
||
488|
|
||
489| handle.addEventListener("mousedown", (e) => {
|
||
490| e.preventDefault();
|
||
491| startY = e.clientY;
|
||
492| startHeight = tagSection.getBoundingClientRect().height;
|
||
493| document.body.classList.add("resizing-v");
|
||
494| handle.classList.add("active");
|
||
495| document.addEventListener("mousemove", onMouseMove);
|
||
496| document.addEventListener("mouseup", onMouseUp);
|
||
497| });
|
||
498|}
|
||
499|
|
||
500|// ---------------------------------------------------------------------------
|
||
501|// Frontmatter Accent Card Builder
|
||
502|// ---------------------------------------------------------------------------
|
||
503|
|
||
504|function buildFrontmatterCard(frontmatter) {
|
||
505| // Helper: format date
|
||
506| function formatDate(iso) {
|
||
507| if (!iso) return "—";
|
||
508| const d = new Date(iso);
|
||
509| const date = d.toISOString().slice(0, 10);
|
||
510| const time = d.toTimeString().slice(0, 5);
|
||
511| return `${date} · ${time}`;
|
||
512| }
|
||
513|
|
||
514| // Extract boolean flags
|
||
515| const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
|
||
516|
|
||
517| // Toggle state
|
||
518| let isOpen = true;
|
||
519|
|
||
520| // Build header with chevron
|
||
521| const chevron = el("span", { class: "fm-chevron open" });
|
||
522| chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
|
||
523|
|
||
524| const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
|
||
525|
|
||
526| // ZONE 1: Top strip
|
||
527| const topBadges = [];
|
||
528|
|
||
529| // Title badge
|
||
530| const title = frontmatter.titre || frontmatter.title || "";
|
||
531| if (title) {
|
||
532| topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)]));
|
||
533| }
|
||
534|
|
||
535| // Status badge
|
||
536| if (frontmatter.statut) {
|
||
537| const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]);
|
||
538| topBadges.push(statusBadge);
|
||
539| }
|
||
540|
|
||
541| // Category badge
|
||
542| if (frontmatter.catégorie || frontmatter.categorie) {
|
||
543| const cat = frontmatter.catégorie || frontmatter.categorie;
|
||
544| const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]);
|
||
545| topBadges.push(catBadge);
|
||
546| }
|
||
547|
|
||
548| // Publish badge
|
||
549| if (frontmatter.publish) {
|
||
550| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")]));
|
||
551| }
|
||
552|
|
||
553| // Favoris badge
|
||
554| if (frontmatter.favoris) {
|
||
555| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")]));
|
||
556| }
|
||
557|
|
||
558| const acTop = el("div", { class: "ac-top" }, topBadges);
|
||
559|
|
||
560| // ZONE 2: Body 2 columns
|
||
561| const leftCol = el("div", { class: "ac-col" }, [
|
||
562| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]),
|
||
563| 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 || "—")])]),
|
||
564| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]),
|
||
565| 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(", ") : "[]")])]),
|
||
566| ]);
|
||
567|
|
||
568| const rightCol = el("div", { class: "ac-col" }, [
|
||
569| 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))])]),
|
||
570| 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))])]),
|
||
571| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]),
|
||
572| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]),
|
||
573| ]);
|
||
574|
|
||
575| const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]);
|
||
576|
|
||
577| // ZONE 3: Tags row
|
||
578| const tagPills = [];
|
||
579| if (frontmatter.tags && frontmatter.tags.length > 0) {
|
||
580| frontmatter.tags.forEach((tag) => {
|
||
581| tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)]));
|
||
582| });
|
||
583| }
|
||
584|
|
||
585| const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]);
|
||
586|
|
||
587| // ZONE 4: Flags row
|
||
588| const flagChips = [];
|
||
589| booleanFlags.forEach((flag) => {
|
||
590| const chipClass = flag.value ? "flag-chip on" : "flag-chip off";
|
||
591| flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)]));
|
||
592| });
|
||
593|
|
||
594| const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]);
|
||
595|
|
||
596| // Assemble the card
|
||
597| const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
|
||
598|
|
||
599| // Toggle functionality
|
||
600| fmHeader.addEventListener("click", () => {
|
||
601| isOpen = !isOpen;
|
||
602| if (isOpen) {
|
||
603| acCard.style.display = "block";
|
||
604| chevron.classList.remove("closed");
|
||
605| chevron.classList.add("open");
|
||
606| } else {
|
||
607| acCard.style.display = "none";
|
||
608| chevron.classList.remove("open");
|
||
609| chevron.classList.add("closed");
|
||
610| }
|
||
611| safeCreateIcons();
|
||
612| });
|
||
613|
|
||
614| // Wrap in section
|
||
615| const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]);
|
||
616|
|
||
617| return fmSection;
|
||
618|}
|
||
619|
|
||
620|// ---------------------------------------------------------------------------
|
||
621|// Context Menu Manager
|
||
622|// ---------------------------------------------------------------------------
|
||
623|export const ContextMenuManager = {
|
||
624| _menu: null,
|
||
625| _targetElement: null,
|
||
626| _targetVault: null,
|
||
627| _targetPath: null,
|
||
628| _targetType: null,
|
||
629|
|
||
630| init() {
|
||
631| this._menu = document.createElement('div');
|
||
632| this._menu.className = 'context-menu';
|
||
633| this._menu.id = 'context-menu';
|
||
634| document.body.appendChild(this._menu);
|
||
635|
|
||
636| document.addEventListener('click', () => this.hide());
|
||
637| document.addEventListener('contextmenu', (e) => {
|
||
638| if (!e.target.closest('.tree-item')) {
|
||
639| this.hide();
|
||
640| }
|
||
641| });
|
||
642|
|
||
643| document.addEventListener('keydown', (e) => {
|
||
644| if (e.key === 'Escape') this.hide();
|
||
645| });
|
||
646|
|
||
647| document.addEventListener('scroll', () => this.hide(), true);
|
||
648| },
|
||
649|
|
||
650| show(x, y, vault, path, type, isReadonly) {
|
||
651| this._targetVault = vault;
|
||
652| this._targetPath = path;
|
||
653| this._targetType = type;
|
||
654|
|
||
655| this._menu.innerHTML = '';
|
||
656|
|
||
657| // Copy path — available for all types
|
||
658| const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`;
|
||
659| this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false);
|
||
660|
|
||
661| // Graph view — available for all types
|
||
662| const graphPath = type === 'vault' ? '' : path;
|
||
663| this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false);
|
||
664|
|
||
665| this._addSeparator();
|
||
666|
|
||
667| if (type === 'vault') {
|
||
668| this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly);
|
||
669| this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly);
|
||
670| } else if (type === 'directory') {
|
||
671| this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
|
||
672| this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
|
||
673| this._addSeparator();
|
||
674| this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||
675| this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
|
||
676| } else if (type === 'file') {
|
||
677| this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||
678| this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
|
||
679| this._addSeparator();
|
||
680| this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false);
|
||
681| }
|
||
682|
|
||
683| this._menu.classList.add('active');
|
||
684|
|
||
685| const rect = this._menu.getBoundingClientRect();
|
||
686| const viewportWidth = window.innerWidth;
|
||
687| const viewportHeight = window.innerHeight;
|
||
688|
|
||
689| let finalX = x;
|
||
690| let finalY = y;
|
||
691|
|
||
692| if (x + rect.width > viewportWidth) {
|
||
693| finalX = viewportWidth - rect.width - 10;
|
||
694| }
|
||
695|
|
||
696| if (y + rect.height > viewportHeight) {
|
||
697| finalY = viewportHeight - rect.height - 10;
|
||
698| }
|
||
699|
|
||
700| this._menu.style.left = `${finalX}px`;
|
||
701| this._menu.style.top = `${finalY}px`;
|
||
702|
|
||
703| safeCreateIcons();
|
||
704| },
|
||
705|
|
||
706| hide() {
|
||
707| if (this._menu) {
|
||
708| this._menu.classList.remove('active');
|
||
709| }
|
||
710| },
|
||
711|
|
||
712| _addItem(icon, label, callback, disabled) {
|
||
713| const item = document.createElement('div');
|
||
714| item.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
||
715| item.innerHTML = `
|
||
716| <i data-lucide="${icon}" class="icon"></i>
|
||
717| <span>${label}</span>
|
||
718| `;
|
||
719|
|
||
720| if (!disabled) {
|
||
721| item.addEventListener('click', (e) => {
|
||
722| e.stopPropagation();
|
||
723| this.hide();
|
||
724| callback();
|
||
725| });
|
||
726| } else {
|
||
727| item.title = 'Vault en lecture seule';
|
||
728| }
|
||
729|
|
||
730| this._menu.appendChild(item);
|
||
731| },
|
||
732|
|
||
733| _addSeparator() {
|
||
734| const sep = document.createElement('div');
|
||
735| sep.className = 'context-menu-separator';
|
||
736| this._menu.appendChild(sep);
|
||
737| },
|
||
738|
|
||
739| _createDirectory() {
|
||
740| FileOperationsManager.showCreateDirectoryModal(this._targetVault, this._targetPath);
|
||
741| },
|
||
742|
|
||
743| _createFile() {
|
||
744| FileOperationsManager.showCreateFileModal(this._targetVault, this._targetPath);
|
||
745| },
|
||
746|
|
||
747| _renameItem() {
|
||
748| FileOperationsManager.startInlineRename(this._targetVault, this._targetPath, this._targetType);
|
||
749| },
|
||
750|
|
||
751| _deleteDirectory() {
|
||
752| FileOperationsManager.confirmDeleteDirectory(this._targetVault, this._targetPath);
|
||
753| },
|
||
754|
|
||
755| _deleteFile() {
|
||
756| FileOperationsManager.confirmDeleteFile(this._targetVault, this._targetPath);
|
||
757| },
|
||
758|
|
||
759| _copyPath(path) {
|
||
760| // Try modern clipboard API first, fall back to execCommand for non-secure contexts
|
||
761| if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
762| navigator.clipboard.writeText(path).then(() => {
|
||
763| showToast(`Chemin copié : ${path}`, 'success');
|
||
764| }).catch(() => {
|
||
765| this._copyPathFallback(path);
|
||
766| });
|
||
767| } else {
|
||
768| this._copyPathFallback(path);
|
||
769| }
|
||
770| },
|
||
771|
|
||
772| _copyPathFallback(path) {
|
||
773| const textarea = document.createElement('textarea');
|
||
774| textarea.value = path;
|
||
775| textarea.style.position = 'fixed';
|
||
776| textarea.style.left = '-9999px';
|
||
777| textarea.style.top = '-9999px';
|
||
778| document.body.appendChild(textarea);
|
||
779| textarea.focus();
|
||
780| textarea.select();
|
||
781| try {
|
||
782| const success = document.execCommand('copy');
|
||
783| if (success) {
|
||
784| showToast(`Chemin copié : ${path}`, 'success');
|
||
785| } else {
|
||
786| showToast('Erreur lors de la copie', 'error');
|
||
787| }
|
||
788| } catch (e) {
|
||
789| showToast('Erreur lors de la copie', 'error');
|
||
790| }
|
||
791| document.body.removeChild(textarea);
|
||
792| },
|
||
793|
|
||
794| async _toggleBookmark() {
|
||
795| try {
|
||
796| const data = await api("/api/bookmarks/toggle", {
|
||
797| method: "POST",
|
||
798| headers: { "Content-Type": "application/json" },
|
||
799| body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }),
|
||
800| });
|
||
801| showToast(data.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
|
||
802| if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
|
||
803| DashboardBookmarkWidget.load();
|
||
804| }
|
||
805| } catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
806| }
|
||
807|};
|
||
808|
|
||
809|// ---------------------------------------------------------------------------
|
||
810|// File Operations Manager
|
||
811|// ---------------------------------------------------------------------------
|
||
812|
|
||
813|export const FileOperationsManager = {
|
||
814| showCreateDirectoryModal(vault, parentPath) {
|
||
815| const overlay = this._createModalOverlay();
|
||
816| const modal = document.createElement('div');
|
||
817| modal.className = 'obsigate-modal';
|
||
818| modal.innerHTML = `
|
||
819| <div class="obsigate-modal-header">
|
||
820| <h3 class="obsigate-modal-title">Créer un dossier</h3>
|
||
821| </div>
|
||
822| <div class="obsigate-modal-body">
|
||
823| <div class="modal-form-group">
|
||
824| <label class="modal-label">Nom du dossier</label>
|
||
825| <input type="text" class="modal-input" id="dir-name-input" placeholder="nouveau-dossier" />
|
||
826| <div class="modal-hint">Dans: ${parentPath || '/'}</div>
|
||
827| <div class="modal-error" id="dir-error" style="display:none;"></div>
|
||
828| </div>
|
||
829| </div>
|
||
830| <div class="obsigate-modal-footer">
|
||
831| <button class="modal-btn" id="dir-cancel-btn">Annuler</button>
|
||
832| <button class="modal-btn primary" id="dir-create-btn">Créer</button>
|
||
833| </div>
|
||
834| `;
|
||
835|
|
||
836| overlay.appendChild(modal);
|
||
837| document.body.appendChild(overlay);
|
||
838|
|
||
839| setTimeout(() => overlay.classList.add('active'), 10);
|
||
840|
|
||
841| const input = modal.querySelector('#dir-name-input');
|
||
842| const errorDiv = modal.querySelector('#dir-error');
|
||
843| const createBtn = modal.querySelector('#dir-create-btn');
|
||
844| const cancelBtn = modal.querySelector('#dir-cancel-btn');
|
||
845|
|
||
846| input.focus();
|
||
847|
|
||
848| const validateName = (name) => {
|
||
849| if (!name.trim()) return 'Le nom ne peut pas être vide';
|
||
850| if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
|
||
851| return null;
|
||
852| };
|
||
853|
|
||
854| input.addEventListener('input', () => {
|
||
855| const error = validateName(input.value);
|
||
856| if (error) {
|
||
857| errorDiv.textContent = error;
|
||
858| errorDiv.style.display = 'block';
|
||
859| input.classList.add('error');
|
||
860| } else {
|
||
861| errorDiv.style.display = 'none';
|
||
862| input.classList.remove('error');
|
||
863| }
|
||
864| });
|
||
865|
|
||
866| const create = async () => {
|
||
867| const name = input.value.trim();
|
||
868| const error = validateName(name);
|
||
869| if (error) {
|
||
870| errorDiv.textContent = error;
|
||
871| errorDiv.style.display = 'block';
|
||
872| return;
|
||
873| }
|
||
874|
|
||
875| const path = parentPath ? `${parentPath}/${name}` : name;
|
||
876| createBtn.disabled = true;
|
||
877| createBtn.textContent = 'Création...';
|
||
878|
|
||
879| try {
|
||
880| await api(`/api/directory/${encodeURIComponent(vault)}`, {
|
||
881| method: 'POST',
|
||
882| headers: { 'Content-Type': 'application/json' },
|
||
883| body: JSON.stringify({ path }),
|
||
884| });
|
||
885|
|
||
886| showToast(`Dossier "${name}" créé`, 'success');
|
||
887| this._closeModal(overlay);
|
||
888| await refreshSidebarTreePreservingState();
|
||
889| } catch (err) {
|
||
890| showToast(err.message || 'Erreur lors de la création', 'error');
|
||
891| createBtn.disabled = false;
|
||
892| createBtn.textContent = 'Créer';
|
||
893| }
|
||
894| };
|
||
895|
|
||
896| createBtn.addEventListener('click', create);
|
||
897| cancelBtn.addEventListener('click', () => this._closeModal(overlay));
|
||
898|
|
||
899| input.addEventListener('keydown', (e) => {
|
||
900| if (e.key === 'Enter') create();
|
||
901| if (e.key === 'Escape') this._closeModal(overlay);
|
||
902| });
|
||
903| },
|
||
904|
|
||
905| showCreateFileModal(vault, parentPath) {
|
||
906| const overlay = this._createModalOverlay();
|
||
907| const modal = document.createElement('div');
|
||
908| modal.className = 'obsigate-modal';
|
||
909| modal.innerHTML = `
|
||
910| <div class="obsigate-modal-header">
|
||
911| <h3 class="obsigate-modal-title">Créer un fichier</h3>
|
||
912| </div>
|
||
913| <div class="obsigate-modal-body">
|
||
914| <div class="modal-form-group">
|
||
915| <label class="modal-label">Nom du fichier</label>
|
||
916| <input type="text" class="modal-input" id="file-name-input" placeholder="note.md" />
|
||
917| <div class="modal-hint">Dans: ${parentPath || '/'}</div>
|
||
918| <div class="modal-error" id="file-error" style="display:none;"></div>
|
||
919| </div>
|
||
920| <div class="modal-form-group">
|
||
921| <label class="modal-label">Type de fichier</label>
|
||
922| <select class="modal-select" id="file-ext-select">
|
||
923| <option value=".md">Markdown (.md)</option>
|
||
924| <option value=".txt">Texte (.txt)</option>
|
||
925| <option value=".py">Python (.py)</option>
|
||
926| <option value=".js">JavaScript (.js)</option>
|
||
927| <option value=".json">JSON (.json)</option>
|
||
928| <option value=".yaml">YAML (.yaml)</option>
|
||
929| <option value=".sh">Shell (.sh)</option>
|
||
930| <option value=".ps1">PowerShell (.ps1)</option>
|
||
931| </select>
|
||
932| </div>
|
||
933| </div>
|
||
934| <div class="obsigate-modal-footer">
|
||
935| <button class="modal-btn" id="file-cancel-btn">Annuler</button>
|
||
936| <button class="modal-btn primary" id="file-create-btn">Créer</button>
|
||
937| </div>
|
||
938| `;
|
||
939|
|
||
940| overlay.appendChild(modal);
|
||
941| document.body.appendChild(overlay);
|
||
942|
|
||
943| setTimeout(() => overlay.classList.add('active'), 10);
|
||
944|
|
||
945| const input = modal.querySelector('#file-name-input');
|
||
946| const extSelect = modal.querySelector('#file-ext-select');
|
||
947| const errorDiv = modal.querySelector('#file-error');
|
||
948| const createBtn = modal.querySelector('#file-create-btn');
|
||
949| const cancelBtn = modal.querySelector('#file-cancel-btn');
|
||
950|
|
||
951| input.focus();
|
||
952|
|
||
953| const validateName = (name) => {
|
||
954| if (!name.trim()) return 'Le nom ne peut pas être vide';
|
||
955| if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
|
||
956| return null;
|
||
957| };
|
||
958|
|
||
959| input.addEventListener('input', () => {
|
||
960| const error = validateName(input.value);
|
||
961| if (error) {
|
||
962| errorDiv.textContent = error;
|
||
963| errorDiv.style.display = 'block';
|
||
964| input.classList.add('error');
|
||
965| } else {
|
||
966| errorDiv.style.display = 'none';
|
||
967| input.classList.remove('error');
|
||
968| }
|
||
969| });
|
||
970|
|
||
971| const create = async () => {
|
||
972| let name = input.value.trim();
|
||
973| const error = validateName(name);
|
||
974| if (error) {
|
||
975| errorDiv.textContent = error;
|
||
976| errorDiv.style.display = 'block';
|
||
977| return;
|
||
978| }
|
||
979|
|
||
980| const ext = extSelect.value;
|
||
981| if (!name.endsWith(ext)) {
|
||
982| name += ext;
|
||
983| }
|
||
984|
|
||
985| const path = parentPath ? `${parentPath}/${name}` : name;
|
||
986| createBtn.disabled = true;
|
||
987| createBtn.textContent = 'Création...';
|
||
988|
|
||
989| try {
|
||
990| await api(`/api/file/${encodeURIComponent(vault)}`, {
|
||
991| method: 'POST',
|
||
992| headers: { 'Content-Type': 'application/json' },
|
||
993| body: JSON.stringify({ path, content: '' }),
|
||
994| });
|
||
995|
|
||
996| showToast(`Fichier "${name}" créé`, 'success');
|
||
997| this._closeModal(overlay);
|
||
998| await refreshSidebarTreePreservingState();
|
||
999| openFile(vault, path);
|
||
1000| } catch (err) {
|
||
1001| showToast(err.message || 'Erreur lors de la création', 'error');
|
||
1002| createBtn.disabled = false;
|
||
1003| createBtn.textContent = 'Créer';
|
||
1004| }
|
||
1005| };
|
||
1006|
|
||
1007| createBtn.addEventListener('click', create);
|
||
1008| cancelBtn.addEventListener('click', () => this._closeModal(overlay));
|
||
1009|
|
||
1010| input.addEventListener('keydown', (e) => {
|
||
1011| if (e.key === 'Enter') create();
|
||
1012| if (e.key === 'Escape') this._closeModal(overlay);
|
||
1013| });
|
||
1014| },
|
||
1015|
|
||
1016| async startInlineRename(vault, path, type) {
|
||
1017| const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`);
|
||
1018| if (!item) {
|
||
1019| showToast('Élément introuvable dans l’arborescence', 'error');
|
||
1020| return;
|
||
1021| }
|
||
1022|
|
||
1023| const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
||
1024| if (!textNode) {
|
||
1025| showToast('Impossible de renommer cet élément', 'error');
|
||
1026| return;
|
||
1027| }
|
||
1028|
|
||
1029| const originalText = textNode.textContent;
|
||
1030| const trimmedOriginal = originalText.trim();
|
||
1031| const currentName = path.split('/').pop() || trimmedOriginal;
|
||
1032| const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName;
|
||
1033| const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : '';
|
||
1034|
|
||
1035| const input = document.createElement('input');
|
||
1036| input.type = 'text';
|
||
1037| input.className = 'sidebar-item-input';
|
||
1038| input.value = baseName;
|
||
1039|
|
||
1040| textNode.textContent = ' ';
|
||
1041| const badge = item.querySelector('.badge-small');
|
||
1042| if (badge) {
|
||
1043| item.insertBefore(input, badge);
|
||
1044| } else {
|
||
1045| item.appendChild(input);
|
||
1046| }
|
||
1047|
|
||
1048| const restore = () => {
|
||
1049| input.remove();
|
||
1050| textNode.textContent = originalText;
|
||
1051| };
|
||
1052|
|
||
1053| const validateName = (name) => {
|
||
1054| if (!name.trim()) return 'Le nom ne peut pas être vide';
|
||
1055| if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
|
||
1056| return null;
|
||
1057| };
|
||
1058|
|
||
1059| const submit = async () => {
|
||
1060| const name = input.value.trim();
|
||
1061| const error = validateName(name);
|
||
1062| if (error) {
|
||
1063| showToast(error, 'error');
|
||
1064| input.focus();
|
||
1065| input.select();
|
||
1066| return;
|
||
1067| }
|
||
1068|
|
||
1069| const newName = `${name}${extension}`;
|
||
1070| if (newName === currentName) {
|
||
1071| restore();
|
||
1072| return;
|
||
1073| }
|
||
1074|
|
||
1075| input.disabled = true;
|
||
1076| try {
|
||
1077| const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`;
|
||
1078| const result = await api(endpoint, {
|
||
1079| method: 'PATCH',
|
||
1080| headers: { 'Content-Type': 'application/json' },
|
||
1081| body: JSON.stringify({ path, new_name: newName }),
|
||
1082| });
|
||
1083|
|
||
1084| const nextPath = result.new_path;
|
||
1085| await refreshSidebarTreePreservingState();
|
||
1086|
|
||
1087| if (type === 'file' && state.currentVault === vault && state.currentPath === path) {
|
||
1088| await openFile(vault, nextPath);
|
||
1089| } else if (type === 'directory' && state.currentVault === vault && currentPath && (state.currentPath === path || state.currentPath.startsWith(`${path}/`))) {
|
||
1090| const suffix = state.currentPath === path ? '' : state.currentPath.slice(path.length);
|
||
1091| state.currentPath = `${nextPath}${suffix}`;
|
||
1092| await focusPathInSidebar(vault, state.currentPath, { alignToTop: false });
|
||
1093| }
|
||
1094|
|
||
1095| showToast(type === 'directory' ? 'Dossier renommé' : 'Fichier renommé', 'success');
|
||
1096| } catch (err) {
|
||
1097| input.disabled = false;
|
||
1098| showToast(err.message || 'Erreur lors du renommage', 'error');
|
||
1099| input.focus();
|
||
1100| input.select();
|
||
1101| return;
|
||
1102| }
|
||
1103| };
|
||
1104|
|
||
1105| input.addEventListener('click', (e) => e.stopPropagation());
|
||
1106| input.addEventListener('keydown', async (e) => {
|
||
1107| e.stopPropagation();
|
||
1108| if (e.key === 'Enter') {
|
||
1109| e.preventDefault();
|
||
1110| await submit();
|
||
1111| }
|
||
1112| if (e.key === 'Escape') {
|
||
1113| e.preventDefault();
|
||
1114| restore();
|
||
1115| }
|
||
1116| });
|
||
1117| input.addEventListener('blur', async () => {
|
||
1118| if (!input.disabled) {
|
||
1119| await submit();
|
||
1120| }
|
||
1121| });
|
||
1122|
|
||
1123| input.focus();
|
||
1124| input.setSelectionRange(0, input.value.length);
|
||
1125| },
|
||
1126|
|
||
1127| confirmDeleteDirectory(vault, path) {
|
||
1128| const overlay = this._createModalOverlay();
|
||
1129| const modal = document.createElement('div');
|
||
1130| modal.className = 'obsigate-modal';
|
||
1131| modal.innerHTML = `
|
||
1132| <div class="obsigate-modal-header">
|
||
1133| <h3 class="obsigate-modal-title">Supprimer le dossier</h3>
|
||
1134| </div>
|
||
1135| <div class="obsigate-modal-body">
|
||
1136| <div class="modal-warning">
|
||
1137| <i data-lucide="alert-triangle" class="icon"></i>
|
||
1138| <div>
|
||
1139| <strong>Attention !</strong> Cette action est irréversible.
|
||
1140| <br>Tous les fichiers et sous-dossiers seront supprimés définitivement.
|
||
1141| </div>
|
||
1142| </div>
|
||
1143| <div class="modal-form-group">
|
||
1144| <label class="modal-label">Dossier à supprimer:</label>
|
||
1145| <div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
||
1146| </div>
|
||
1147| </div>
|
||
1148| <div class="obsigate-modal-footer">
|
||
1149| <button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
||
1150| <button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
||
1151| </div>
|
||
1152| `;
|
||
1153|
|
||
1154| overlay.appendChild(modal);
|
||
1155| document.body.appendChild(overlay);
|
||
1156|
|
||
1157| setTimeout(() => overlay.classList.add('active'), 10);
|
||
1158| safeCreateIcons();
|
||
1159|
|
||
1160| const confirmBtn = modal.querySelector('#del-confirm-btn');
|
||
1161| const cancelBtn = modal.querySelector('#del-cancel-btn');
|
||
1162|
|
||
1163| const deleteDir = async () => {
|
||
1164| confirmBtn.disabled = true;
|
||
1165| confirmBtn.textContent = 'Suppression...';
|
||
1166|
|
||
1167| try {
|
||
1168| const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
|
||
1169| method: 'DELETE',
|
||
1170| });
|
||
1171|
|
||
1172| showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success');
|
||
1173| this._closeModal(overlay);
|
||
1174| await refreshSidebarTreePreservingState();
|
||
1175|
|
||
1176| if (state.currentVault === vault && currentPath && state.currentPath.startsWith(path)) {
|
||
1177| showWelcome();
|
||
1178| }
|
||
1179| } catch (err) {
|
||
1180| showToast(err.message || 'Erreur lors de la suppression', 'error');
|
||
1181| confirmBtn.disabled = false;
|
||
1182| confirmBtn.textContent = 'Supprimer définitivement';
|
||
1183| }
|
||
1184| };
|
||
1185|
|
||
1186| confirmBtn.addEventListener('click', deleteDir);
|
||
1187| cancelBtn.addEventListener('click', () => this._closeModal(overlay));
|
||
1188| },
|
||
1189|
|
||
1190| confirmDeleteFile(vault, path) {
|
||
1191| const overlay = this._createModalOverlay();
|
||
1192| const modal = document.createElement('div');
|
||
1193| modal.className = 'obsigate-modal';
|
||
1194| modal.innerHTML = `
|
||
1195| <div class="obsigate-modal-header">
|
||
1196| <h3 class="obsigate-modal-title">Supprimer le fichier</h3>
|
||
1197| </div>
|
||
1198| <div class="obsigate-modal-body">
|
||
1199| <div class="modal-warning">
|
||
1200| <i data-lucide="alert-triangle" class="icon"></i>
|
||
1201| <div>
|
||
1202| <strong>Attention !</strong> Cette action est irréversible.
|
||
1203| </div>
|
||
1204| </div>
|
||
1205| <div class="modal-form-group">
|
||
1206| <label class="modal-label">Fichier à supprimer:</label>
|
||
1207| <div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
|
||
1208| </div>
|
||
1209| </div>
|
||
1210| <div class="obsigate-modal-footer">
|
||
1211| <button class="modal-btn" id="del-cancel-btn">Annuler</button>
|
||
1212| <button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
|
||
1213| </div>
|
||
1214| `;
|
||
1215|
|
||
1216| overlay.appendChild(modal);
|
||
1217| document.body.appendChild(overlay);
|
||
1218|
|
||
1219| setTimeout(() => overlay.classList.add('active'), 10);
|
||
1220| safeCreateIcons();
|
||
1221|
|
||
1222| const confirmBtn = modal.querySelector('#del-confirm-btn');
|
||
1223| const cancelBtn = modal.querySelector('#del-cancel-btn');
|
||
1224|
|
||
1225| const deleteFile = async () => {
|
||
1226| confirmBtn.disabled = true;
|
||
1227| confirmBtn.textContent = 'Suppression...';
|
||
1228|
|
||
1229| try {
|
||
1230| await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
|
||
1231| method: 'DELETE',
|
||
1232| });
|
||
1233|
|
||
1234| showToast('Fichier supprimé', 'success');
|
||
1235| this._closeModal(overlay);
|
||
1236| await refreshSidebarTreePreservingState();
|
||
1237|
|
||
1238| if (state.currentVault === vault && state.currentPath === path) {
|
||
1239| showWelcome();
|
||
1240| }
|
||
1241| } catch (err) {
|
||
1242| showToast(err.message || 'Erreur lors de la suppression', 'error');
|
||
1243| confirmBtn.disabled = false;
|
||
1244| confirmBtn.textContent = 'Supprimer définitivement';
|
||
1245| }
|
||
1246| };
|
||
1247|
|
||
1248| confirmBtn.addEventListener('click', deleteFile);
|
||
1249| cancelBtn.addEventListener('click', () => this._closeModal(overlay));
|
||
1250| },
|
||
1251|
|
||
1252| _createModalOverlay() {
|
||
1253| const overlay = document.createElement('div');
|
||
1254| overlay.className = 'obsigate-modal-overlay';
|
||
1255| overlay.addEventListener('click', (e) => {
|
||
1256| if (e.target === overlay) {
|
||
1257| this._closeModal(overlay);
|
||
1258| }
|
||
1259| });
|
||
1260| return overlay;
|
||
1261| },
|
||
1262|
|
||
1263| _closeModal(overlay) {
|
||
1264| overlay.classList.remove('active');
|
||
1265| setTimeout(() => overlay.remove(), 200);
|
||
1266| }
|
||
1267|};
|
||
1268|
|
||
1269|// ---------------------------------------------------------------------------
|
||
1270|// Find in Page Manager
|
||
1271|// ---------------------------------------------------------------------------
|
||
1272|export const FindInPageManager = {
|
||
1273| isOpen: false,
|
||
1274| searchTerm: "",
|
||
1275| matches: [],
|
||
1276| currentIndex: -1,
|
||
1277| options: {
|
||
1278| caseSensitive: false,
|
||
1279| wholeWord: false,
|
||
1280| useRegex: false,
|
||
1281| },
|
||
1282| debounceTimer: null,
|
||
1283| previousFocus: null,
|
||
1284|
|
||
1285| init() {
|
||
1286| const bar = document.getElementById("find-in-page-bar");
|
||
1287| const input = document.getElementById("find-input");
|
||
1288| const prevBtn = document.getElementById("find-prev");
|
||
1289| const nextBtn = document.getElementById("find-next");
|
||
1290| const closeBtn = document.getElementById("find-close");
|
||
1291| const caseSensitiveBtn = document.getElementById("find-case-sensitive");
|
||
1292| const wholeWordBtn = document.getElementById("find-whole-word");
|
||
1293| const regexBtn = document.getElementById("find-regex");
|
||
1294|
|
||
1295| if (!bar || !input) return;
|
||
1296|
|
||
1297| // Keyboard shortcuts
|
||
1298| document.addEventListener("keydown", (e) => {
|
||
1299| // Ctrl+F or Cmd+F to open
|
||
1300| if ((e.ctrlKey || e.metaKey) && e.key === "f") {
|
||
1301| e.preventDefault();
|
||
1302| this.open();
|
||
1303| }
|
||
1304| // Escape to close
|
||
1305| if (e.key === "Escape" && this.isOpen) {
|
||
1306| e.preventDefault();
|
||
1307| this.close();
|
||
1308| }
|
||
1309| // Enter to go to next
|
||
1310| if (e.key === "Enter" && this.isOpen && document.activeElement === input) {
|
||
1311| e.preventDefault();
|
||
1312| if (e.shiftKey) {
|
||
1313| this.goToPrevious();
|
||
1314| } else {
|
||
1315| this.goToNext();
|
||
1316| }
|
||
1317| }
|
||
1318| // F3 for next/previous
|
||
1319| if (e.key === "F3" && this.isOpen) {
|
||
1320| e.preventDefault();
|
||
1321| if (e.shiftKey) {
|
||
1322| this.goToPrevious();
|
||
1323| } else {
|
||
1324| this.goToNext();
|
||
1325| }
|
||
1326| }
|
||
1327| });
|
||
1328|
|
||
1329| // Input event with debounce
|
||
1330| input.addEventListener("input", (e) => {
|
||
1331| clearTimeout(this.debounceTimer);
|
||
1332| this.debounceTimer = setTimeout(() => {
|
||
1333| this.search(e.target.value);
|
||
1334| }, 250);
|
||
1335| });
|
||
1336|
|
||
1337| // Navigation buttons
|
||
1338| prevBtn.addEventListener("click", () => this.goToPrevious());
|
||
1339| nextBtn.addEventListener("click", () => this.goToNext());
|
||
1340|
|
||
1341| // Close button
|
||
1342| closeBtn.addEventListener("click", () => this.close());
|
||
1343|
|
||
1344| // Option toggles
|
||
1345| caseSensitiveBtn.addEventListener("click", () => {
|
||
1346| this.options.caseSensitive = !this.options.caseSensitive;
|
||
1347| caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
|
||
1348| this.saveState();
|
||
1349| if (this.searchTerm) this.search(this.searchTerm);
|
||
1350| });
|
||
1351|
|
||
1352| wholeWordBtn.addEventListener("click", () => {
|
||
1353| this.options.wholeWord = !this.options.wholeWord;
|
||
1354| wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
|
||
1355| this.saveState();
|
||
1356| if (this.searchTerm) this.search(this.searchTerm);
|
||
1357| });
|
||
1358|
|
||
1359| regexBtn.addEventListener("click", () => {
|
||
1360| this.options.useRegex = !this.options.useRegex;
|
||
1361| regexBtn.setAttribute("aria-pressed", this.options.useRegex);
|
||
1362| this.saveState();
|
||
1363| if (this.searchTerm) this.search(this.searchTerm);
|
||
1364| });
|
||
1365|
|
||
1366| // Load saved state
|
||
1367| this.loadState();
|
||
1368| },
|
||
1369|
|
||
1370| open() {
|
||
1371| const bar = document.getElementById("find-in-page-bar");
|
||
1372| const input = document.getElementById("find-input");
|
||
1373| if (!bar || !input) return;
|
||
1374|
|
||
1375| this.previousFocus = document.activeElement;
|
||
1376| this.isOpen = true;
|
||
1377| bar.hidden = false;
|
||
1378| input.focus();
|
||
1379| input.select();
|
||
1380| safeCreateIcons();
|
||
1381| },
|
||
1382|
|
||
1383| close() {
|
||
1384| const bar = document.getElementById("find-in-page-bar");
|
||
1385| if (!bar) return;
|
||
1386|
|
||
1387| this.isOpen = false;
|
||
1388| bar.hidden = true;
|
||
1389| this.clearHighlights();
|
||
1390| this.matches = [];
|
||
1391| this.currentIndex = -1;
|
||
1392| this.searchTerm = "";
|
||
1393|
|
||
1394| // Restore previous focus
|
||
1395| if (this.previousFocus && this.previousFocus.focus) {
|
||
1396| this.previousFocus.focus();
|
||
1397| }
|
||
1398| },
|
||
1399|
|
||
1400| search(term) {
|
||
1401| this.searchTerm = term;
|
||
1402| this.clearHighlights();
|
||
1403| this.hideError();
|
||
1404|
|
||
1405| if (!term || term.trim().length === 0) {
|
||
1406| this.updateCounter();
|
||
1407| this.updateNavButtons();
|
||
1408| return;
|
||
1409| }
|
||
1410|
|
||
1411| const contentArea = document.querySelector(".md-content");
|
||
1412| if (!contentArea) {
|
||
1413| this.updateCounter();
|
||
1414| this.updateNavButtons();
|
||
1415| return;
|
||
1416| }
|
||
1417|
|
||
1418| try {
|
||
1419| const regex = this.createRegex(term);
|
||
1420| this.matches = [];
|
||
1421| this.findMatches(contentArea, regex);
|
||
1422| this.currentIndex = this.matches.length > 0 ? 0 : -1;
|
||
1423| this.highlightMatches();
|
||
1424| this.updateCounter();
|
||
1425| this.updateNavButtons();
|
||
1426|
|
||
1427| if (this.matches.length > 0) {
|
||
1428| this.scrollToMatch(0);
|
||
1429| }
|
||
1430| } catch (err) {
|
||
1431| this.showError(err.message);
|
||
1432| this.matches = [];
|
||
1433| this.currentIndex = -1;
|
||
1434| this.updateCounter();
|
||
1435| this.updateNavButtons();
|
||
1436| }
|
||
1437| },
|
||
1438|
|
||
1439| createRegex(term) {
|
||
1440| let pattern = term;
|
||
1441|
|
||
1442| if (!this.options.useRegex) {
|
||
1443| // Escape special regex characters
|
||
1444| pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
1445| }
|
||
1446|
|
||
1447| if (this.options.wholeWord) {
|
||
1448| pattern = "\\b" + pattern + "\\b";
|
||
1449| }
|
||
1450|
|
||
1451| const flags = this.options.caseSensitive ? "g" : "gi";
|
||
1452| return new RegExp(pattern, flags);
|
||
1453| },
|
||
1454|
|
||
1455| findMatches(container, regex) {
|
||
1456| const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||
1457| acceptNode: (node) => {
|
||
1458| // Skip code blocks, scripts, styles
|
||
1459| const parent = node.parentElement;
|
||
1460| if (!parent) return NodeFilter.FILTER_REJECT;
|
||
1461| const tagName = parent.tagName.toLowerCase();
|
||
1462| if (["code", "pre", "script", "style"].includes(tagName)) {
|
||
1463| return NodeFilter.FILTER_REJECT;
|
||
1464| }
|
||
1465| // Skip empty text nodes
|
||
1466| if (!node.textContent || node.textContent.trim().length === 0) {
|
||
1467| return NodeFilter.FILTER_REJECT;
|
||
1468| }
|
||
1469| return NodeFilter.FILTER_ACCEPT;
|
||
1470| },
|
||
1471| });
|
||
1472|
|
||
1473| let node;
|
||
1474| while ((node = walker.nextNode())) {
|
||
1475| const text = node.textContent;
|
||
1476| let match;
|
||
1477| regex.lastIndex = 0; // Reset regex
|
||
1478|
|
||
1479| while ((match = regex.exec(text)) !== null) {
|
||
1480| this.matches.push({
|
||
1481| node: node,
|
||
1482| index: match.index,
|
||
1483| length: match[0].length,
|
||
1484| text: match[0],
|
||
1485| });
|
||
1486|
|
||
1487| // Prevent infinite loop with zero-width matches
|
||
1488| if (match.index === regex.lastIndex) {
|
||
1489| regex.lastIndex++;
|
||
1490| }
|
||
1491| }
|
||
1492| }
|
||
1493| },
|
||
1494|
|
||
1495| highlightMatches() {
|
||
1496| const matchesByNode = new Map();
|
||
1497|
|
||
1498| this.matches.forEach((match, idx) => {
|
||
1499| if (!matchesByNode.has(match.node)) {
|
||
1500| matchesByNode.set(match.node, []);
|
||
1501| }
|
||
1502| matchesByNode.get(match.node).push({ match, idx });
|
||
1503| });
|
||
1504|
|
||
1505| matchesByNode.forEach((entries, node) => {
|
||
1506| if (!node || !node.parentNode) return;
|
||
1507|
|
||
1508| const text = node.textContent || "";
|
||
1509| let cursor = 0;
|
||
1510| const fragment = document.createDocumentFragment();
|
||
1511|
|
||
1512| entries.sort((a, b) => a.match.index - b.match.index);
|
||
1513|
|
||
1514| entries.forEach(({ match, idx }) => {
|
||
1515| if (match.index > cursor) {
|
||
1516| fragment.appendChild(document.createTextNode(text.substring(cursor, match.index)));
|
||
1517| }
|
||
1518|
|
||
1519| const matchText = text.substring(match.index, match.index + match.length);
|
||
1520| const mark = document.createElement("mark");
|
||
1521| mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight";
|
||
1522| mark.textContent = matchText;
|
||
1523| mark.setAttribute("data-find-index", idx);
|
||
1524| fragment.appendChild(mark);
|
||
1525|
|
||
1526| match.element = mark;
|
||
1527| cursor = match.index + match.length;
|
||
1528| });
|
||
1529|
|
||
1530| if (cursor < text.length) {
|
||
1531| fragment.appendChild(document.createTextNode(text.substring(cursor)));
|
||
1532| }
|
||
1533|
|
||
1534| node.parentNode.replaceChild(fragment, node);
|
||
1535| });
|
||
1536| },
|
||
1537|
|
||
1538| clearHighlights() {
|
||
1539| const contentArea = document.querySelector(".md-content");
|
||
1540| if (!contentArea) return;
|
||
1541|
|
||
1542| const marks = contentArea.querySelectorAll("mark.find-highlight");
|
||
1543| marks.forEach((mark) => {
|
||
1544| if (!mark.parentNode) return;
|
||
1545| const text = mark.textContent;
|
||
1546| const textNode = document.createTextNode(text);
|
||
1547| mark.parentNode.replaceChild(textNode, mark);
|
||
1548| });
|
||
1549|
|
||
1550| // Normalize text nodes to merge adjacent text nodes
|
||
1551| contentArea.normalize();
|
||
1552| },
|
||
1553|
|
||
1554| goToNext() {
|
||
1555| if (this.matches.length === 0) return;
|
||
1556|
|
||
1557| // Remove active class from current
|
||
1558| if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
|
||
1559| this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
|
||
1560| }
|
||
1561|
|
||
1562| // Move to next (with wrapping)
|
||
1563| this.currentIndex = (this.currentIndex + 1) % this.matches.length;
|
||
1564|
|
||
1565| // Add active class to new current
|
||
1566| if (this.matches[this.currentIndex].element) {
|
||
1567| this.matches[this.currentIndex].element.classList.add("find-highlight-active");
|
||
1568| }
|
||
1569|
|
||
1570| this.scrollToMatch(this.currentIndex);
|
||
1571| this.updateCounter();
|
||
1572| },
|
||
1573|
|
||
1574| goToPrevious() {
|
||
1575| if (this.matches.length === 0) return;
|
||
1576|
|
||
1577| // Remove active class from current
|
||
1578| if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
|
||
1579| this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
|
||
1580| }
|
||
1581|
|
||
1582| // Move to previous (with wrapping)
|
||
1583| this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1;
|
||
1584|
|
||
1585| // Add active class to new current
|
||
1586| if (this.matches[this.currentIndex].element) {
|
||
1587| this.matches[this.currentIndex].element.classList.add("find-highlight-active");
|
||
1588| }
|
||
1589|
|
||
1590| this.scrollToMatch(this.currentIndex);
|
||
1591| this.updateCounter();
|
||
1592| },
|
||
1593|
|
||
1594| scrollToMatch(index) {
|
||
1595| if (index < 0 || index >= this.matches.length) return;
|
||
1596|
|
||
1597| const match = this.matches[index];
|
||
1598| if (!match.element) return;
|
||
1599|
|
||
1600| const contentArea = document.getElementById("content-area");
|
||
1601| if (!contentArea) {
|
||
1602| match.element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
1603| return;
|
||
1604| }
|
||
1605|
|
||
1606| // Calculate position with offset for header
|
||
1607| const elementTop = match.element.offsetTop;
|
||
1608| const offset = 100; // Offset for header
|
||
1609|
|
||
1610| contentArea.scrollTo({
|
||
1611| top: elementTop - offset,
|
||
1612| behavior: "smooth",
|
||
1613| });
|
||
1614| },
|
||
1615|
|
||
1616| updateCounter() {
|
||
1617| const counter = document.getElementById("find-counter");
|
||
1618| if (!counter) return;
|
||
1619|
|
||
1620| const count = this.matches.length;
|
||
1621| if (count === 0) {
|
||
1622| counter.textContent = "0 occurrence";
|
||
1623| } else if (count === 1) {
|
||
1624| counter.textContent = "1 occurrence";
|
||
1625| } else {
|
||
1626| counter.textContent = `${count} occurrences`;
|
||
1627| }
|
||
1628| },
|
||
1629|
|
||
1630| updateNavButtons() {
|
||
1631| const prevBtn = document.getElementById("find-prev");
|
||
1632| const nextBtn = document.getElementById("find-next");
|
||
1633| if (!prevBtn || !nextBtn) return;
|
||
1634|
|
||
1635| const hasMatches = this.matches.length > 0;
|
||
1636| prevBtn.disabled = !hasMatches;
|
||
1637| nextBtn.disabled = !hasMatches;
|
||
1638| },
|
||
1639|
|
||
1640| showError(message) {
|
||
1641| const errorEl = document.getElementById("find-error");
|
||
1642| if (!errorEl) return;
|
||
1643|
|
||
1644| errorEl.textContent = message;
|
||
1645| errorEl.hidden = false;
|
||
1646| },
|
||
1647|
|
||
1648| hideError() {
|
||
1649| const errorEl = document.getElementById("find-error");
|
||
1650| if (!errorEl) return;
|
||
1651|
|
||
1652| errorEl.hidden = true;
|
||
1653| },
|
||
1654|
|
||
1655| saveState() {
|
||
1656| try {
|
||
1657| const state = {
|
||
1658| options: this.options,
|
||
1659| };
|
||
1660| localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state));
|
||
1661| } catch (e) {
|
||
1662| // Ignore localStorage errors
|
||
1663| }
|
||
1664| },
|
||
1665|
|
||
1666| loadState() {
|
||
1667| try {
|
||
1668| const saved = localStorage.getItem("obsigate-find-in-page-state");
|
||
1669| if (saved) {
|
||
1670| const state = JSON.parse(saved);
|
||
1671| if (state.options) {
|
||
1672| this.options = { ...this.options, ...state.options };
|
||
1673|
|
||
1674| // Update button states
|
||
1675| const caseSensitiveBtn = document.getElementById("find-case-sensitive");
|
||
1676| const wholeWordBtn = document.getElementById("find-whole-word");
|
||
1677| const regexBtn = document.getElementById("find-regex");
|
||
1678|
|
||
1679| if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
|
||
1680| if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
|
||
1681| if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex);
|
||
1682| }
|
||
1683| }
|
||
1684| } catch (e) {
|
||
1685| // Ignore localStorage errors
|
||
1686| }
|
||
1687| },
|
||
1688|};
|
||
1689|
|
||
1690|// ---------------------------------------------------------------------------
|
||
1691|// Tab Manager
|
||
1692|// ---------------------------------------------------------------------------
|
||
1693|export const TabManager = {
|
||
1694| _tabs: [],
|
||
1695| _activeTabId: null,
|
||
1696| _previewTabId: null, // single-click preview tab (temporary, replaced on next preview)
|
||
1697| _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } }
|
||
1698| _tabBar: null,
|
||
1699| _tabList: null,
|
||
1700| _dirtyTabs: new Set(),
|
||
1701|
|
||
1702| init() {
|
||
1703| this._tabBar = document.getElementById("tab-bar");
|
||
1704| this._tabList = document.getElementById("tab-list");
|
||
1705| },
|
||
1706|
|
||
1707| /** Open a file as a preview tab (single-click).
|
||
1708| * Replaces any existing preview tab. If the file is already
|
||
1709| * open as a persistent tab, just activates it. */
|
||
1710| async openPreview(vault, path) {
|
||
1711| const tabId = `${vault}::${path}`;
|
||
1712|
|
||
1713| // If already open as persistent tab, just activate it
|
||
1714| const existing = this._tabs.find(t => t.id === tabId && !t.preview);
|
||
1715| if (existing) {
|
||
1716| this.activate(tabId);
|
||
1717| return;
|
||
1718| }
|
||
1719|
|
||
1720| // Close existing preview tab
|
||
1721| if (this._previewTabId && this._previewTabId !== tabId) {
|
||
1722| this.close(this._previewTabId);
|
||
1723| }
|
||
1724|
|
||
1725| // If already open as preview, just focus it
|
||
1726| const previewExisting = this._tabs.find(t => t.id === tabId && t.preview);
|
||
1727| if (previewExisting) {
|
||
1728| this.activate(tabId);
|
||
1729| return;
|
||
1730| }
|
||
1731|
|
||
1732| // Create preview tab
|
||
1733| const name = path.split("/").pop().replace(/\.md$/i, "");
|
||
1734| const icon = getFileIcon(name + ".md");
|
||
1735|
|
||
1736| this._tabs.push({ id: tabId, vault, path, name, icon, preview: true });
|
||
1737| this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
|
||
1738| this._previewTabId = tabId;
|
||
1739|
|
||
1740| this._renderTabs();
|
||
1741| this.activate(tabId);
|
||
1742| },
|
||
1743|
|
||
1744| /** Convert a preview tab to a persistent tab (double-click).
|
||
1745| * If already persistent, opens a new duplicate (same file, different tab). */
|
||
1746| async openPersistent(vault, path) {
|
||
1747| const tabId = `${vault}::${path}`;
|
||
1748|
|
||
1749| // If it's already a preview tab, convert it to persistent
|
||
1750| const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
|
||
1751| if (previewTab) {
|
||
1752| previewTab.preview = false;
|
||
1753| if (this._previewTabId === tabId) {
|
||
1754| this._previewTabId = null;
|
||
1755| }
|
||
1756| this._renderTabs();
|
||
1757| this.activate(tabId);
|
||
1758| return;
|
||
1759| }
|
||
1760|
|
||
1761| // If already persistent, just focus it
|
||
1762| const existing = this._tabs.find(t => t.id === tabId && !t.preview);
|
||
1763| if (existing) {
|
||
1764| this.activate(tabId);
|
||
1765| return;
|
||
1766| }
|
||
1767|
|
||
1768| // Create a new persistent tab
|
||
1769| this.open(vault, path);
|
||
1770| },
|
||
1771|
|
||
1772| /** Open a file in a tab (or focus existing) */
|
||
1773| async open(vault, path, options = {}) {
|
||
1774| const tabId = `${vault}::${path}`;
|
||
1775|
|
||
1776| // If already open, just focus it
|
||
1777| const existing = this._tabs.find(t => t.id === tabId);
|
||
1778| if (existing) {
|
||
1779| // Convert preview to persistent if needed
|
||
1780| if (existing.preview) {
|
||
1781| existing.preview = false;
|
||
1782| if (this._previewTabId === tabId) this._previewTabId = null;
|
||
1783| this._renderTabs();
|
||
1784| }
|
||
1785| this.activate(tabId);
|
||
1786| return;
|
||
1787| }
|
||
1788|
|
||
1789| // Create new tab
|
||
1790| const name = path.split("/").pop().replace(/\.md$/i, "");
|
||
1791| const icon = getFileIcon(name + ".md");
|
||
1792|
|
||
1793| this._tabs.push({ id: tabId, vault, path, name, icon });
|
||
1794| this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
|
||
1795|
|
||
1796| this._renderTabs();
|
||
1797| this.activate(tabId);
|
||
1798| },
|
||
1799|
|
||
1800| /** Activate a specific tab */
|
||
1801| async activate(tabId) {
|
||
1802| if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
||
1803|
|
||
1804| // Save current tab state
|
||
1805| if (this._activeTabId && this._tabCache[this._activeTabId]) {
|
||
1806| this._saveCurrentTabState();
|
||
1807| }
|
||
1808|
|
||
1809| this._activeTabId = tabId;
|
||
1810| this._renderTabs();
|
||
1811|
|
||
1812| // Load tab content
|
||
1813| const cache = this._tabCache[tabId];
|
||
1814| if (!cache) return;
|
||
1815|
|
||
1816| // Update global state
|
||
1817| state.currentVault = cache.vault;
|
||
1818| state.currentPath = cache.path;
|
||
1819| syncActiveFileTreeItem(cache.vault, cache.path);
|
||
1820|
|
||
1821| const area = document.getElementById("content-area");
|
||
1822|
|
||
1823| if (cache.data) {
|
||
1824| // Use cached data
|
||
1825| this._restoreTabContent(cache, area);
|
||
1826| } else {
|
||
1827| // Fetch file content
|
||
1828| area.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Chargement...</div>';
|
||
1829| try {
|
||
1830| const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`);
|
||
1831| cache.data = data;
|
||
1832| cache.title = data.title;
|
||
1833| renderFile(cache.data);
|
||
1834|
|
||
1835| // Restore source view if needed
|
||
1836| if (cache.sourceView) {
|
||
1837| await this._toggleSourceView(cache, area);
|
||
1838| }
|
||
1839| if (cache.scrollTop) {
|
||
1840| area.scrollTop = cache.scrollTop;
|
||
1841| }
|
||
1842| } catch (err) {
|
||
1843| area.innerHTML = `<div style="padding:40px;text-align:center;color:var(--text-error)">Erreur: ${escapeHtml(err.message)}</div>`;
|
||
1844| }
|
||
1845| }
|
||
1846|
|
||
1847| // Update URL hash
|
||
1848| if (history.pushState) {
|
||
1849| history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
|
||
1850| }
|
||
1851|
|
||
1852| // Hide dashboard
|
||
1853| const dashboard = document.getElementById("dashboard-home");
|
||
1854| if (dashboard) dashboard.style.display = "none";
|
||
1855| },
|
||
1856|
|
||
1857| /** Close a tab */
|
||
1858| close(tabId) {
|
||
1859| const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
1860| if (idx === -1) return;
|
||
1861|
|
||
1862| this._tabs.splice(idx, 1);
|
||
1863| delete this._tabCache[tabId];
|
||
1864| this._dirtyTabs.delete(tabId);
|
||
1865|
|
||
1866| if (this._tabs.length === 0) {
|
||
1867| this._activeTabId = null;
|
||
1868| this._showDashboard();
|
||
1869| this._tabBar.hidden = true;
|
||
1870| } else if (this._activeTabId === tabId) {
|
||
1871| // Activate adjacent tab
|
||
1872| const newIdx = Math.min(idx, this._tabs.length - 1);
|
||
1873| this.activate(this._tabs[newIdx].id);
|
||
1874| }
|
||
1875|
|
||
1876| this._renderTabs();
|
||
1877| },
|
||
1878|
|
||
1879| /** Close all tabs */
|
||
1880| closeAll() {
|
||
1881| this._tabs = [];
|
||
1882| this._tabCache = {};
|
||
1883| this._dirtyTabs.clear();
|
||
1884| this._activeTabId = null;
|
||
1885| this._showDashboard();
|
||
1886| this._tabBar.hidden = true;
|
||
1887| },
|
||
1888|
|
||
1889| /** Close tabs to the right */
|
||
1890| closeRight(tabId) {
|
||
1891| const idx = this._tabs.findIndex(t => t.id === tabId);
|
||
1892| if (idx === -1) return;
|
||
1893| const toClose = this._tabs.slice(idx + 1);
|
||
1894| for (const tab of toClose) {
|
||
1895| delete this._tabCache[tab.id];
|
||
1896| this._dirtyTabs.delete(tab.id);
|
||
1897| }
|
||
1898| this._tabs = this._tabs.slice(0, idx + 1);
|
||
1899| if (!this._tabs.find(t => t.id === this._activeTabId)) {
|
||
1900| this.activate(tabId);
|
||
1901| }
|
||
1902| this._renderTabs();
|
||
1903| },
|
||
1904|
|
||
1905| /** Close other tabs */
|
||
1906| closeOthers(tabId) {
|
||
1907| const tab = this._tabs.find(t => t.id === tabId);
|
||
1908| if (!tab) return;
|
||
1909| for (const t of this._tabs) {
|
||
1910| if (t.id !== tabId) {
|
||
1911| delete this._tabCache[t.id];
|
||
1912| this._dirtyTabs.delete(t.id);
|
||
1913| }
|
||
1914| }
|
||
1915| this._tabs = [tab];
|
||
1916| this.activate(tabId);
|
||
1917| this._renderTabs();
|
||
1918| },
|
||
1919|
|
||
1920| /** Reorder tabs by drag and drop */
|
||
1921| moveTab(fromIdx, toIdx) {
|
||
1922| if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
|
||
1923| const tab = this._tabs.splice(fromIdx, 1)[0];
|
||
1924| this._tabs.splice(toIdx, 0, tab);
|
||
1925| this._renderTabs();
|
||
1926| },
|
||
1927|
|
||
1928| /** Save current tab state before switching */
|
||
1929| _saveCurrentTabState() {
|
||
1930| const cache = this._tabCache[this._activeTabId];
|
||
1931| if (!cache) return;
|
||
1932|
|
||
1933| const area = document.getElementById("content-area");
|
||
1934| const rendered = document.getElementById("file-rendered-content");
|
||
1935|
|
||
1936| cache.scrollTop = area.scrollTop;
|
||
1937| cache.sourceView = rendered ? rendered.style.display === "none" : false;
|
||
1938| },
|
||
1939|
|
||
1940| /** Restore tab content from cache */
|
||
1941| _restoreTabContent(cache, area) {
|
||
1942| renderFile(cache.data);
|
||
1943| if (cache.sourceView) {
|
||
1944| this._restoreSourceView(cache, area);
|
||
1945| }
|
||
1946| if (cache.scrollTop) {
|
||
1947| area.scrollTop = cache.scrollTop;
|
||
1948| }
|
||
1949| },
|
||
1950|
|
||
1951| async _toggleSourceView(cache, area) {
|
||
1952| const rendered = document.getElementById("file-rendered-content");
|
||
1953| const raw = document.getElementById("file-raw-content");
|
||
1954| if (!rendered || !raw) return;
|
||
1955|
|
||
1956| if (!cache.rawSource) {
|
||
1957| const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`);
|
||
1958| cache.rawSource = rawData.raw;
|
||
1959| }
|
||
1960| raw.textContent = cache.rawSource;
|
||
1961| rendered.style.display = "none";
|
||
1962| raw.style.display = "block";
|
||
1963| },
|
||
1964|
|
||
1965| _restoreSourceView(cache, area) {
|
||
1966| requestAnimationFrame(() => {
|
||
1967| const rendered = document.getElementById("file-rendered-content");
|
||
1968| const raw = document.getElementById("file-raw-content");
|
||
1969| if (rendered && raw && cache.rawSource) {
|
||
1970| raw.textContent = cache.rawSource;
|
||
1971| rendered.style.display = "none";
|
||
1972| raw.style.display = "block";
|
||
1973| }
|
||
1974| });
|
||
1975| },
|
||
1976|
|
||
1977| _showDashboard() {
|
||
1978| const area = document.getElementById("content-area");
|
||
1979| // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
|
||
1980| let dashboard = document.getElementById("dashboard-home");
|
||
1981| if (!dashboard) {
|
||
1982| // Dashboard was destroyed — rebuild via showWelcome
|
||
1983| area.innerHTML = "";
|
||
1984| showWelcome();
|
||
1985| return;
|
||
1986| }
|
||
1987| area.innerHTML = "";
|
||
1988| dashboard.style.display = "";
|
||
1989| area.appendChild(dashboard);
|
||
1990| // Refresh widgets after restoring
|
||
1991| if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load();
|
||
1992| if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load();
|
||
1993| if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(state.selectedContextVault);
|
||
1994| if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(state.selectedContextVault);
|
||
1995| if (history.pushState) {
|
||
1996| history.pushState(null, "", "#");
|
||
1997| }
|
||
1998| },
|
||
1999|
|
||
2000| /** Render the tab bar */
|
||
2001| |