Bruno Charest 7866f93778
All checks were successful
CI / lint (push) Successful in 13s
CI / security (push) Successful in 8s
CI / test (push) Successful in 16s
CI / build (push) Successful in 6s
refactor: state.js → mutable object to fix 'assignment to constant' errors
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.
2026-05-28 16:34:39 -04:00

2001 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 larborescence', '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|