diff --git a/ROADMAP.md b/ROADMAP.md index 5abb1f6..f5ef822 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,9 +79,9 @@ - Export dans la vue publique `/s/{token}/pdf` βœ… - GTK/Pango installΓ© dans le Dockerfile βœ… -### 3. Palette de commandes (Ctrl+P) -- **Effort** : 1 jour | **Impact** : 🟑 -- Navigation rapide style VS Code dans l'interface +### βœ… 3. Palette de commandes (Ctrl+P) β€” FAIT +- Navigation rapide fichiers + commandes (> prefix) βœ… +- Module `frontend/js/palette.js` + intΓ©grΓ© dans app.js βœ… ### 4. Drag & drop de fichiers - **Effort** : 1-2 jours | **Impact** : 🟑 diff --git a/frontend/js/app.js b/frontend/js/app.js index 18bf117..0f45232 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -13,6 +13,7 @@ import * as Viewer from './viewer.js'; import * as Sync from './sync.js'; import { initGraphView } from './graph.js'; import * as Legacy from './legacy.js'; +import { initCommandPalette } from './palette.js'; // ========================================================================= // Initialization β€” mirrors the original app.js init() ordering @@ -44,6 +45,7 @@ async function init() { UI.FindInPageManager.init(); UI.ContextMenuManager.init(); UI.TabManager.init(); + initCommandPalette(); const authOk = await Auth.AuthManager.initAuth(); diff --git a/frontend/js/palette.js b/frontend/js/palette.js new file mode 100644 index 0000000..8fd72e5 --- /dev/null +++ b/frontend/js/palette.js @@ -0,0 +1,287 @@ +/* ObsiGate β€” Command Palette (Ctrl+P) + Quick file opener and command runner. + Inspired by VS Code's Ctrl+P / Ctrl+Shift+P. +*/ +import { api } from './auth.js'; +import { openFile } from './viewer.js'; +import { showToast, toggleTheme } from './ui.js'; +import { state } from './state.js'; + +// ── Helper: trigger goHome by clicking the logo ── +function triggerGoHome() { + const logo = document.getElementById('header-logo'); + if (logo) logo.click(); +} + +// ── DOM cache (created in open) ── +let _overlay = null; +let _input = null; +let _results = null; +let _mode = 'files'; // 'files' | 'commands' +let _selectedIndex = 0; +let _searchTimeout = null; +let _lastResults = []; + +// ── Available commands ── +const COMMANDS = [ + { id: 'home', label: '🏠 Accueil', description: 'Retour Γ  l\'accueil', action: () => { close(); triggerGoHome(); } }, + { id: 'theme', label: 'πŸŒ“ Changer le thΓ¨me', description: 'Basculer clair/sombre', action: () => { close(); toggleTheme(); } }, + { id: 'reindex', label: 'πŸ”„ RΓ©indexer', description: 'Forcer la rΓ©indexation complΓ¨te', action: async () => { close(); try { await api('/api/index/reload'); showToast('Index rechargΓ©', 'success'); } catch { showToast('Erreur de rΓ©indexation', 'error'); } } }, + { id: 'help', label: '❓ Aide', description: 'Ouvrir l\'aide', action: () => { close(); const helpBtn = document.getElementById('header-help-btn'); if (helpBtn) helpBtn.click(); } }, + { id: 'config', label: 'βš™οΈ Configuration', description: 'Ouvrir les paramΓ¨tres', action: () => { close(); document.querySelector('[data-tab-config]')?.click(); } }, +]; + +// ── Create DOM structure ── +function createDOM() { + if (_overlay) return; + + _overlay = document.createElement('div'); + _overlay.className = 'command-palette-overlay'; + _overlay.innerHTML = ` +
+
+ + ${navigator.platform.includes('Mac') ? '⌘P' : 'Ctrl+P'} +
+
+ +
+ `; + + _input = _overlay.querySelector('.cp-input'); + _results = _overlay.querySelector('.cp-results'); + + // Click outside to close (on overlay, not the palette dialog) + _overlay.addEventListener('click', (e) => { + if (e.target === _overlay) close(); + }); + + // Keyboard events + _input.addEventListener('keydown', onKeyDown); + _input.addEventListener('input', onInput); + + document.body.appendChild(_overlay); +} + +// ── Open / Close ── +export function open(initialQuery = '') { + createDOM(); + _overlay.classList.add('active'); + _input.value = initialQuery; + _mode = initialQuery.startsWith('>') ? 'commands' : 'files'; + _selectedIndex = 0; + _lastResults = []; + _results.innerHTML = ''; + _input.focus(); + // Trigger initial search + onInput(); +} + +export function close() { + if (!_overlay) return; + _overlay.classList.remove('active'); + if (_searchTimeout) clearTimeout(_searchTimeout); + _searchTimeout = null; + // Restore focus + const searchInput = document.querySelector('.search-input'); + if (searchInput) searchInput.focus(); +} + +function isOpen() { + return _overlay && _overlay.classList.contains('active'); +} + +// ── Input handler ── +function onInput() { + const val = _input.value; + + // Detect mode change + const newMode = val.startsWith('>') ? 'commands' : 'files'; + if (newMode !== _mode) { + _mode = newMode; + _input.placeholder = _mode === 'commands' + ? 'Tapez une commande...' + : 'Rechercher un fichier... (tapez > pour les commandes)'; + } + + if (_searchTimeout) clearTimeout(_searchTimeout); + _searchTimeout = setTimeout(() => { + _searchTimeout = null; + if (_mode === 'commands') { + searchCommands(val.slice(1).trim()); + } else { + searchFiles(val.trim()); + } + }, 150); +} + +// ── Search files (via /api/suggest) ── +async function searchFiles(query) { + if (!query) { + _results.innerHTML = '
Commencez Γ  taper pour chercher un fichier
'; + _lastResults = []; + return; + } + + try { + const data = await api(`/api/suggest?q=${encodeURIComponent(query)}&vault=all`); + const suggestions = data.suggestions || []; + _lastResults = suggestions.map(s => ({ + type: 'file', + vault: s.vault, + path: s.path, + title: s.title, + label: s.title || s.path.split('/').pop(), + sublabel: `${s.vault}/${s.path}`, + })); + renderResults(); + } catch { + _results.innerHTML = '
Erreur de recherche
'; + } +} + +// ── Search commands ── +function searchCommands(query) { + if (!query) { + _lastResults = COMMANDS.map(c => ({ type: 'command', ...c })); + renderResults(); + return; + } + + const lower = query.toLowerCase(); + _lastResults = COMMANDS + .filter(c => c.label.toLowerCase().includes(lower) || c.description.toLowerCase().includes(lower)) + .map(c => ({ type: 'command', ...c })); + renderResults(); +} + +// ── Render results list ── +function renderResults() { + _selectedIndex = 0; + + if (_lastResults.length === 0) { + _results.innerHTML = '
Aucun rΓ©sultat
'; + return; + } + + _results.innerHTML = _lastResults.map((item, i) => ` +
+
${escapeHtml(item.label)}
+
${escapeHtml(item.sublabel || item.description || '')}
+
+ `).join(''); + + // Click handler + _results.querySelectorAll('.cp-item').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.index); + executeItem(idx); + }); + el.addEventListener('mouseenter', () => { + document.querySelectorAll('.cp-item').forEach(e => e.classList.remove('cp-selected')); + el.classList.add('cp-selected'); + _selectedIndex = parseInt(el.dataset.index); + }); + }); + + scrollToSelected(); +} + +// ── Keyboard navigation ── +function onKeyDown(e) { + const items = _results.querySelectorAll('.cp-item'); + + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } else if (e.key === 'Enter') { + e.preventDefault(); + executeItem(_selectedIndex); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + _selectedIndex = Math.min(_selectedIndex + 1, _lastResults.length - 1); + updateSelection(items); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + _selectedIndex = Math.max(_selectedIndex - 1, 0); + updateSelection(items); + } else if (e.key === 'Tab') { + e.preventDefault(); + // Switch between files/commands mode + if (_mode === 'files') { + _input.value = '> '; + // Move cursor to end + setTimeout(() => { + _input.selectionStart = _input.selectionEnd = _input.value.length; + }, 0); + } else { + _input.value = ''; + } + onInput(); + } +} + +function updateSelection(items) { + items.forEach((el, i) => { + el.classList.toggle('cp-selected', i === _selectedIndex); + }); + scrollToSelected(); +} + +function scrollToSelected() { + const selected = _results.querySelector('.cp-selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } +} + +// ── Execute ── +function executeItem(index) { + const item = _lastResults[index]; + if (!item) return; + + if (item.type === 'file') { + close(); + openFile(item.vault, item.path); + } else if (item.type === 'command') { + item.action(); + } +} + +// ── HTML escaping ── +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ── Init: Register global keyboard shortcut ── +export function initCommandPalette() { + document.addEventListener('keydown', (e) => { + // Ctrl+P or Cmd+P β€” open palette + if ((e.ctrlKey || e.metaKey) && e.key === 'p') { + e.preventDefault(); + if (isOpen()) { + close(); + } else { + open(''); + } + } + // Ctrl+Shift+P or Cmd+Shift+P β€” open in command mode + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'p') { + e.preventDefault(); + if (isOpen()) { + close(); + } else { + open('> '); + } + } + }); +} \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css index 5aa937d..8bf5e2e 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -6132,3 +6132,160 @@ body.popup-mode .content-area { .dashboard-panel.active { display: block; } + +/* ═══════════════════════════════════════════════════════════════════ + Command Palette (Ctrl+P) + ═══════════════════════════════════════════════════════════════════ */ + +.command-palette-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: 0 0; + z-index: 10000; + display: flex; + justify-content: center; + padding-top: 12vh; + pointer-events: none; + opacity: 0; + transition: opacity 0.1s ease, background 0.1s ease; +} + +.command-palette-overlay.active { + opacity: 1; + background: var(--overlay-bg); + pointer-events: auto; +} + +.command-palette { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 40px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.03); + width: 580px; + max-width: calc(100vw - 40px); + max-height: 60vh; + display: flex; + flex-direction: column; + overflow: hidden; + animation: cp-slide-in 0.12s ease-out; +} + +@keyframes cp-slide-in { + from { transform: translateY(-12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.cp-header { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); + padding: 0 14px; +} + +.cp-input { + flex: 1; + border: none; + background: none; + padding: 14px 0; + font-size: 16px; + color: var(--text-primary); + outline: none; + font-family: inherit; +} + +.cp-input::placeholder { + color: var(--text-muted); +} + +.cp-hint { + font-size: 11px; + color: var(--text-muted); + background: var(--bg-hover); + padding: 3px 8px; + border-radius: 4px; + font-family: "JetBrains Mono", monospace; + opacity: 0.7; +} + +.cp-results { + flex: 1; + overflow-y: auto; + padding: 6px 0; + max-height: 40vh; +} + +.cp-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.cp-error { + color: var(--accent-orange, #d29922); +} + +.cp-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 14px; + cursor: pointer; + border-left: 3px solid transparent; + /* Uncomment for smooth scroll perf */; +} + +.cp-item:hover { + background: var(--bg-hover); + border-left-color: var(--accent); +} + +.cp-item.cp-selected { + background: var(--accent-bg); + border-left-color: var(--accent); +} + +.cp-item-label { + font-size: 14px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cp-item-sublabel { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 45%; + text-align: right; + margin-left: 12px; +} + +.cp-footer { + display: flex; + gap: 14px; + padding: 8px 14px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); +} + +.cp-footer span { + display: flex; + align-items: center; + gap: 4px; +} + +.cp-footer span::before { + content: ''; + display: inline-block; + width: 3px; height: 3px; + background: var(--text-muted); + border-radius: 50%; + margin-right: 4px; +} + +.cp-footer span:first-child::before { display: none; }