diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 702cb36..20bc7c7 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1579,11 +1579,6 @@ async function init() { } -// ---- Modify openFile to use TabManager ---- -const _originalOpenFile = openFile; -openFile = function(vault, path) { - TabManager.open(vault, path); -}; // ---- Keyboard shortcuts for tabs ---- document.addEventListener("keydown", (e) => { @@ -1621,3 +1616,1099 @@ init = function() { }; + +// ===== MISSING MANAGERS (extracted separately) ===== + +export const ContextMenuManager = { + _menu: null, + _targetElement: null, + _targetVault: null, + _targetPath: null, + _targetType: null, + + init() { + this._menu = document.createElement('div'); + this._menu.className = 'context-menu'; + this._menu.id = 'context-menu'; + document.body.appendChild(this._menu); + + document.addEventListener('click', () => this.hide()); + document.addEventListener('contextmenu', (e) => { + if (!e.target.closest('.tree-item')) { + this.hide(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.hide(); + }); + + document.addEventListener('scroll', () => this.hide(), true); + }, + + show(x, y, vault, path, type, isReadonly) { + this._targetVault = vault; + this._targetPath = path; + this._targetType = type; + + this._menu.innerHTML = ''; + + // Copy path — available for all types + const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; + this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); + + // Graph view — available for all types + const graphPath = type === 'vault' ? '' : path; + this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); + + this._addSeparator(); + + if (type === 'vault') { + this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); + this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); + } else if (type === 'directory') { + this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); + this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); + this._addSeparator(); + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); + } else if (type === 'file') { + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); + this._addSeparator(); + this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false); + } + + this._menu.classList.add('active'); + + const rect = this._menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let finalX = x; + let finalY = y; + + if (x + rect.width > viewportWidth) { + finalX = viewportWidth - rect.width - 10; + } + + if (y + rect.height > viewportHeight) { + finalY = viewportHeight - rect.height - 10; + } + + this._menu.style.left = `${finalX}px`; + this._menu.style.top = `${finalY}px`; + + safeCreateIcons(); + }, + + hide() { + if (this._menu) { + this._menu.classList.remove('active'); + } + }, + + _addItem(icon, label, callback, disabled) { + const item = document.createElement('div'); + item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); + item.innerHTML = ` + + ${label} + `; + + if (!disabled) { + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.hide(); + callback(); + }); + } else { + item.title = 'Vault en lecture seule'; + } + + this._menu.appendChild(item); + }, + + _addSeparator() { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + this._menu.appendChild(sep); + }, + + _createDirectory() { + FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath); + }, + + _createFile() { + FileOperations.showCreateFileModal(this._targetVault, this._targetPath); + }, + + _renameItem() { + FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType); + }, + + _deleteDirectory() { + FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath); + }, + + _deleteFile() { + FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); + }, + + _copyPath(path) { + // Try modern clipboard API first, fall back to execCommand for non-secure contexts + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(path).then(() => { + showToast(`Chemin copié : ${path}`, 'success'); + }).catch(() => { + this._copyPathFallback(path); + }); + } else { + this._copyPathFallback(path); + } + }, + + _copyPathFallback(path) { + const textarea = document.createElement('textarea'); + textarea.value = path; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + const success = document.execCommand('copy'); + if (success) { + showToast(`Chemin copié : ${path}`, 'success'); + } else { + showToast('Erreur lors de la copie', 'error'); + } + } catch (e) { + showToast('Erreur lors de la copie', 'error'); + } + document.body.removeChild(textarea); + }, + + async _toggleBookmark() { + try { + const data = await api("/api/bookmarks/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }), + }); + showToast(data.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); + if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { + DashboardBookmarkWidget.load(); + } + } catch (err) { showToast("Erreur: " + err.message, "error"); } + } +}; + + +export const FindInPageManager = { + isOpen: false, + searchTerm: "", + matches: [], + currentIndex: -1, + options: { + caseSensitive: false, + wholeWord: false, + useRegex: false, + }, + debounceTimer: null, + previousFocus: null, + + init() { + const bar = document.getElementById("find-in-page-bar"); + const input = document.getElementById("find-input"); + const prevBtn = document.getElementById("find-prev"); + const nextBtn = document.getElementById("find-next"); + const closeBtn = document.getElementById("find-close"); + const caseSensitiveBtn = document.getElementById("find-case-sensitive"); + const wholeWordBtn = document.getElementById("find-whole-word"); + const regexBtn = document.getElementById("find-regex"); + + if (!bar || !input) return; + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + // Ctrl+F or Cmd+F to open + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + e.preventDefault(); + this.open(); + } + // Escape to close + if (e.key === "Escape" && this.isOpen) { + e.preventDefault(); + this.close(); + } + // Enter to go to next + if (e.key === "Enter" && this.isOpen && document.activeElement === input) { + e.preventDefault(); + if (e.shiftKey) { + this.goToPrevious(); + } else { + this.goToNext(); + } + } + // F3 for next/previous + if (e.key === "F3" && this.isOpen) { + e.preventDefault(); + if (e.shiftKey) { + this.goToPrevious(); + } else { + this.goToNext(); + } + } + }); + + // Input event with debounce + input.addEventListener("input", (e) => { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.search(e.target.value); + }, 250); + }); + + // Navigation buttons + prevBtn.addEventListener("click", () => this.goToPrevious()); + nextBtn.addEventListener("click", () => this.goToNext()); + + // Close button + closeBtn.addEventListener("click", () => this.close()); + + // Option toggles + caseSensitiveBtn.addEventListener("click", () => { + this.options.caseSensitive = !this.options.caseSensitive; + caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + wholeWordBtn.addEventListener("click", () => { + this.options.wholeWord = !this.options.wholeWord; + wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + regexBtn.addEventListener("click", () => { + this.options.useRegex = !this.options.useRegex; + regexBtn.setAttribute("aria-pressed", this.options.useRegex); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + // Load saved state + this.loadState(); + }, + + open() { + const bar = document.getElementById("find-in-page-bar"); + const input = document.getElementById("find-input"); + if (!bar || !input) return; + + this.previousFocus = document.activeElement; + this.isOpen = true; + bar.hidden = false; + input.focus(); + input.select(); + safeCreateIcons(); + }, + + close() { + const bar = document.getElementById("find-in-page-bar"); + if (!bar) return; + + this.isOpen = false; + bar.hidden = true; + this.clearHighlights(); + this.matches = []; + this.currentIndex = -1; + this.searchTerm = ""; + + // Restore previous focus + if (this.previousFocus && this.previousFocus.focus) { + this.previousFocus.focus(); + } + }, + + search(term) { + this.searchTerm = term; + this.clearHighlights(); + this.hideError(); + + if (!term || term.trim().length === 0) { + this.updateCounter(); + this.updateNavButtons(); + return; + } + + const contentArea = document.querySelector(".md-content"); + if (!contentArea) { + this.updateCounter(); + this.updateNavButtons(); + return; + } + + try { + const regex = this.createRegex(term); + this.matches = []; + this.findMatches(contentArea, regex); + this.currentIndex = this.matches.length > 0 ? 0 : -1; + this.highlightMatches(); + this.updateCounter(); + this.updateNavButtons(); + + if (this.matches.length > 0) { + this.scrollToMatch(0); + } + } catch (err) { + this.showError(err.message); + this.matches = []; + this.currentIndex = -1; + this.updateCounter(); + this.updateNavButtons(); + } + }, + + createRegex(term) { + let pattern = term; + + if (!this.options.useRegex) { + // Escape special regex characters + pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + if (this.options.wholeWord) { + pattern = "\\b" + pattern + "\\b"; + } + + const flags = this.options.caseSensitive ? "g" : "gi"; + return new RegExp(pattern, flags); + }, + + findMatches(container, regex) { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + // Skip code blocks, scripts, styles + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + const tagName = parent.tagName.toLowerCase(); + if (["code", "pre", "script", "style"].includes(tagName)) { + return NodeFilter.FILTER_REJECT; + } + // Skip empty text nodes + if (!node.textContent || node.textContent.trim().length === 0) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let node; + while ((node = walker.nextNode())) { + const text = node.textContent; + let match; + regex.lastIndex = 0; // Reset regex + + while ((match = regex.exec(text)) !== null) { + this.matches.push({ + node: node, + index: match.index, + length: match[0].length, + text: match[0], + }); + + // Prevent infinite loop with zero-width matches + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + } + }, + + highlightMatches() { + const matchesByNode = new Map(); + + this.matches.forEach((match, idx) => { + if (!matchesByNode.has(match.node)) { + matchesByNode.set(match.node, []); + } + matchesByNode.get(match.node).push({ match, idx }); + }); + + matchesByNode.forEach((entries, node) => { + if (!node || !node.parentNode) return; + + const text = node.textContent || ""; + let cursor = 0; + const fragment = document.createDocumentFragment(); + + entries.sort((a, b) => a.match.index - b.match.index); + + entries.forEach(({ match, idx }) => { + if (match.index > cursor) { + fragment.appendChild(document.createTextNode(text.substring(cursor, match.index))); + } + + const matchText = text.substring(match.index, match.index + match.length); + const mark = document.createElement("mark"); + mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight"; + mark.textContent = matchText; + mark.setAttribute("data-find-index", idx); + fragment.appendChild(mark); + + match.element = mark; + cursor = match.index + match.length; + }); + + if (cursor < text.length) { + fragment.appendChild(document.createTextNode(text.substring(cursor))); + } + + node.parentNode.replaceChild(fragment, node); + }); + }, + + clearHighlights() { + const contentArea = document.querySelector(".md-content"); + if (!contentArea) return; + + const marks = contentArea.querySelectorAll("mark.find-highlight"); + marks.forEach((mark) => { + if (!mark.parentNode) return; + const text = mark.textContent; + const textNode = document.createTextNode(text); + mark.parentNode.replaceChild(textNode, mark); + }); + + // Normalize text nodes to merge adjacent text nodes + contentArea.normalize(); + }, + + goToNext() { + if (this.matches.length === 0) return; + + // Remove active class from current + if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); + } + + // Move to next (with wrapping) + this.currentIndex = (this.currentIndex + 1) % this.matches.length; + + // Add active class to new current + if (this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.add("find-highlight-active"); + } + + this.scrollToMatch(this.currentIndex); + this.updateCounter(); + }, + + goToPrevious() { + if (this.matches.length === 0) return; + + // Remove active class from current + if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); + } + + // Move to previous (with wrapping) + this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; + + // Add active class to new current + if (this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.add("find-highlight-active"); + } + + this.scrollToMatch(this.currentIndex); + this.updateCounter(); + }, + + scrollToMatch(index) { + if (index < 0 || index >= this.matches.length) return; + + const match = this.matches[index]; + if (!match.element) return; + + const contentArea = document.getElementById("content-area"); + if (!contentArea) { + match.element.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + + // Calculate position with offset for header + const elementTop = match.element.offsetTop; + const offset = 100; // Offset for header + + contentArea.scrollTo({ + top: elementTop - offset, + behavior: "smooth", + }); + }, + + updateCounter() { + const counter = document.getElementById("find-counter"); + if (!counter) return; + + const count = this.matches.length; + if (count === 0) { + counter.textContent = "0 occurrence"; + } else if (count === 1) { + counter.textContent = "1 occurrence"; + } else { + counter.textContent = `${count} occurrences`; + } + }, + + updateNavButtons() { + const prevBtn = document.getElementById("find-prev"); + const nextBtn = document.getElementById("find-next"); + if (!prevBtn || !nextBtn) return; + + const hasMatches = this.matches.length > 0; + prevBtn.disabled = !hasMatches; + nextBtn.disabled = !hasMatches; + }, + + showError(message) { + const errorEl = document.getElementById("find-error"); + if (!errorEl) return; + + errorEl.textContent = message; + errorEl.hidden = false; + }, + + hideError() { + const errorEl = document.getElementById("find-error"); + if (!errorEl) return; + + errorEl.hidden = true; + }, + + saveState() { + try { + const state = { + options: this.options, + }; + localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state)); + } catch (e) { + // Ignore localStorage errors + } + }, + + loadState() { + try { + const saved = localStorage.getItem("obsigate-find-in-page-state"); + if (saved) { + const state = JSON.parse(saved); + if (state.options) { + this.options = { ...this.options, ...state.options }; + + // Update button states + const caseSensitiveBtn = document.getElementById("find-case-sensitive"); + const wholeWordBtn = document.getElementById("find-whole-word"); + const regexBtn = document.getElementById("find-regex"); + + if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); + if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); + if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex); + } + } + } catch (e) { + // Ignore localStorage errors + } + }, +}; + + +export const TabManager = { + _tabs: [], + _activeTabId: null, + _previewTabId: null, // single-click preview tab (temporary, replaced on next preview) + _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } + _tabBar: null, + _tabList: null, + _dirtyTabs: new Set(), + + init() { + this._tabBar = document.getElementById("tab-bar"); + this._tabList = document.getElementById("tab-list"); + }, + + /** Open a file as a preview tab (single-click). + * Replaces any existing preview tab. If the file is already + * open as a persistent tab, just activates it. */ + async openPreview(vault, path) { + const tabId = `${vault}::${path}`; + + // If already open as persistent tab, just activate it + const existing = this._tabs.find(t => t.id === tabId && !t.preview); + if (existing) { + this.activate(tabId); + return; + } + + // Close existing preview tab + if (this._previewTabId && this._previewTabId !== tabId) { + this.close(this._previewTabId); + } + + // If already open as preview, just focus it + const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); + if (previewExisting) { + this.activate(tabId); + return; + } + + // Create preview tab + const name = path.split("/").pop().replace(/\.md$/i, ""); + const icon = getFileIcon(name + ".md"); + + this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + this._previewTabId = tabId; + + this._renderTabs(); + this.activate(tabId); + }, + + /** Convert a preview tab to a persistent tab (double-click). + * If already persistent, opens a new duplicate (same file, different tab). */ + async openPersistent(vault, path) { + const tabId = `${vault}::${path}`; + + // If it's already a preview tab, convert it to persistent + const previewTab = this._tabs.find(t => t.id === tabId && t.preview); + if (previewTab) { + previewTab.preview = false; + if (this._previewTabId === tabId) { + this._previewTabId = null; + } + this._renderTabs(); + this.activate(tabId); + return; + } + + // If already persistent, just focus it + const existing = this._tabs.find(t => t.id === tabId && !t.preview); + if (existing) { + this.activate(tabId); + return; + } + + // Create a new persistent tab + this.open(vault, path); + }, + + /** Open a file in a tab (or focus existing) */ + async open(vault, path, options = {}) { + const tabId = `${vault}::${path}`; + + // If already open, just focus it + const existing = this._tabs.find(t => t.id === tabId); + if (existing) { + // Convert preview to persistent if needed + if (existing.preview) { + existing.preview = false; + if (this._previewTabId === tabId) this._previewTabId = null; + this._renderTabs(); + } + this.activate(tabId); + return; + } + + // Create new tab + const name = path.split("/").pop().replace(/\.md$/i, ""); + const icon = getFileIcon(name + ".md"); + + this._tabs.push({ id: tabId, vault, path, name, icon }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + + this._renderTabs(); + this.activate(tabId); + }, + + /** Activate a specific tab */ + async activate(tabId) { + if (this._activeTabId === tabId && this._tabs.length > 0) return; + + // Save current tab state + if (this._activeTabId && this._tabCache[this._activeTabId]) { + this._saveCurrentTabState(); + } + + this._activeTabId = tabId; + this._renderTabs(); + + // Load tab content + const cache = this._tabCache[tabId]; + if (!cache) return; + + // Update global state + currentVault = cache.vault; + currentPath = cache.path; + syncActiveFileTreeItem(cache.vault, cache.path); + + const area = document.getElementById("content-area"); + + if (cache.data) { + // Use cached data + this._restoreTabContent(cache, area); + } else { + // Fetch file content + area.innerHTML = '