From 40a8801220ee07b03dad01c0d51998aabf83775c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 3 Jun 2026 07:29:36 -0400 Subject: [PATCH] Add AI toolbar to popout HTML editor --- frontend/popout.html | 283 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/frontend/popout.html b/frontend/popout.html index d23651b..f83dad9 100644 --- a/frontend/popout.html +++ b/frontend/popout.html @@ -927,6 +927,11 @@ const RightSidebarManager = { cachedRawSource = rawData.raw; bodyEl.innerHTML = ""; + // Create AI toolbar container (before CodeMirror) + const aiContainer = document.createElement('div'); + aiContainer.id = 'ai-toolbar-container'; + bodyEl.appendChild(aiContainer); + if (editorView) { editorView.destroy(); editorView = null; @@ -1008,6 +1013,12 @@ const RightSidebarManager = { } modal.classList.add("active"); + // Initialize AI toolbar (after editor is ready) + if (window._popoutAIToolbarCreated) { + // Update the existing toolbar's editor reference + window._popoutAIToolbarCreated = false; + } + createPopoutAIToolbar(aiContainer, () => editorView); safeCreateIcons(); } @@ -1021,6 +1032,278 @@ const RightSidebarManager = { fallbackEditorEl = null; editorVault = null; editorPath = null; + // Clean up AI toolbar + const aiContainer = document.getElementById('ai-toolbar-container'); + if (aiContainer) aiContainer.innerHTML = ''; + } + + // ── AI Toolbar (inline, same as main app's ai.js) ── + + async function popoutFetch(url, opts = {}) { + const res = await fetch(url, { ...opts, credentials: 'include' }); + if (!res.ok) { + let detail = ''; + try { const err = await res.json(); detail = err.detail || ''; } catch {} + throw new Error(detail || `HTTP ${res.status}`); + } + return res.json(); + } + + async function popoutAiAction(endpoint, text, extra = {}) { + const body = { text, ...extra }; + const data = await popoutFetch(`/api/ai/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return data.result; + } + + function popoutGetSelection(ev) { + if (!ev) return ''; + const { from, to } = ev.state.selection.main; + if (from === to) return ev.state.doc.toString(); + return ev.state.doc.sliceString(from, to); + } + + function popoutReplaceSelection(ev, newText, mode = 'replace') { + if (!ev) return; + const { from, to } = ev.state.selection.main; + if (mode === 'append') { + ev.dispatch({ changes: { from: to, insert: '\n' + newText }, selection: { anchor: to + 1 + newText.length } }); + } else if (mode === 'before') { + ev.dispatch({ changes: { from, insert: newText + '\n' } }); + } else { + ev.dispatch({ changes: { from, to, insert: newText }, selection: { anchor: from + newText.length } }); + } + } + + function popoutShowToast(msg, type) { + const existing = document.querySelector('.popout-toast'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.className = 'popout-toast'; + const colors = { success: '#22c55e', error: '#ef4444', info: '#3b82f6', warning: '#f59e0b' }; + Object.assign(toast.style, { + position: 'fixed', bottom: '16px', right: '16px', zIndex: '9999', + background: 'var(--bg-secondary)', color: 'var(--text-primary)', + padding: '8px 16px', borderRadius: '6px', fontSize: '0.8rem', + borderLeft: `3px solid ${colors[type] || colors.info}`, + boxShadow: '0 2px 8px rgba(0,0,0,0.3)', + transition: 'opacity 0.3s', opacity: '1', + }); + toast.textContent = msg; + document.body.appendChild(toast); + setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2500); + } + + function popoutCreateMenu(items, parentEl) { + const existing = parentEl.querySelector('.ai-dropdown-menu'); + if (existing) existing.remove(); + const menu = document.createElement('div'); + menu.className = 'ai-dropdown-menu'; + Object.assign(menu.style, { + position: 'absolute', top: '100%', left: '0', + background: 'var(--bg-primary)', border: '1px solid var(--border-color)', + borderRadius: '6px', padding: '4px 0', minWidth: '200px', zIndex: '100', + boxShadow: '0 4px 16px rgba(0,0,0,0.4)', + }); + items.forEach(item => { + const el = document.createElement('div'); + el.className = 'ai-menu-item'; + Object.assign(el.style, { + padding: '6px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', + gap: '8px', fontSize: '0.8rem', color: 'var(--text-primary)', + whiteSpace: 'nowrap', background: 'transparent', + }); + el.innerHTML = item.label; + if (item.hint) { + const hint = document.createElement('span'); + hint.style.cssText = 'margin-left:auto;font-size:0.65rem;color:var(--text-muted)'; + hint.textContent = item.hint; + el.appendChild(hint); + } + el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; }); + el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; }); + el.addEventListener('click', (e) => { + e.stopPropagation(); + if (item.action) item.action(); + menu.remove(); + }); + menu.appendChild(el); + }); + parentEl.appendChild(menu); + const closeHandler = (e) => { + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', closeHandler); } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 0); + } + + function popoutCreateDropdownBtn(text, items, tooltip) { + const btn = document.createElement('button'); + btn.className = 'ai-toolbar-btn'; + btn.innerHTML = text + ' ▾'; + btn.title = tooltip || text; + Object.assign(btn.style, { + position: 'relative', fontSize: '0.7rem', background: 'none', + border: 'none', cursor: 'pointer', padding: '3px 6px', + borderRadius: '4px', color: 'var(--text-primary)', + }); + btn.addEventListener('click', (e) => { e.stopPropagation(); popoutCreateMenu(items, btn); }); + btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.08)'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); + return btn; + } + + async function createPopoutAIToolbar(container, getEditorView) { + if (window._popoutAIToolbarCreated) return; + window._popoutAIToolbarCreated = true; + + // Check if AI is configured + let aiConfigured = false; + try { + const status = await popoutFetch('/api/ai/status'); + aiConfigured = status.configured; + } catch { aiConfigured = false; } + + if (!aiConfigured) { + const hint = document.createElement('div'); + hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border-color)'; + hint.textContent = 'AI non configuré — ajouter une clé API dans .env'; + container.appendChild(hint); + return; + } + + function ev() { return getEditorView(); } + + const toolbar = document.createElement('div'); + toolbar.className = 'ai-toolbar'; + Object.assign(toolbar.style, { + display: 'flex', alignItems: 'center', gap: '2px', padding: '4px 8px', + borderBottom: '1px solid var(--border-color)', background: '#2a2a2a', flexWrap: 'wrap', + }); + + // AI inline complete button + const aiBtn = document.createElement('button'); + aiBtn.className = 'ai-toolbar-btn'; + aiBtn.innerHTML = '✦ AI'; + Object.assign(aiBtn.style, { + color: '#60a5fa', fontWeight: '600', fontSize: '0.75rem', + background: 'none', border: 'none', cursor: 'pointer', + padding: '3px 8px', borderRadius: '4px', + }); + aiBtn.title = 'AI Inline Completion (Ctrl+J)'; + aiBtn.addEventListener('click', async () => { + const v = ev(); if (!v) return; + const text = popoutGetSelection(v); + if (!text.trim()) return; + const origHTML = aiBtn.innerHTML; + aiBtn.innerHTML = '⏳ AI...'; aiBtn.disabled = true; + try { + const result = await popoutAiAction('inline-complete', text); + popoutReplaceSelection(v, result, 'append'); + popoutShowToast('AI: complétion ajoutée', 'success'); + } catch (e) { + popoutShowToast('AI: ' + (String(e.message).includes('401') ? 'clé API invalide' : e.message), 'error'); + } finally { + aiBtn.innerHTML = origHTML; aiBtn.disabled = false; + } + }); + + async function popoutAction(endpoint, extra = {}, mode = 'replace') { + const v = ev(); if (!v) return; + const text = popoutGetSelection(v); + if (!text.trim()) { popoutShowToast('AI: sélectionnez du texte', 'warning'); return; } + popoutShowToast('⏳ AI: traitement...', 'info'); + try { + const result = await popoutAiAction(endpoint, text, extra); + popoutReplaceSelection(v, result, mode); + popoutShowToast('AI: ✓', 'success'); + } catch (e) { + const msg = String(e.message); + if (msg.includes('401')) popoutShowToast('AI: clé API invalide', 'error'); + else if (msg.includes('402') || msg.includes('429')) popoutShowToast('AI: quota dépassé', 'error'); + else popoutShowToast('AI: ' + msg, 'error'); + } + } + + const sep = (() => { const s = document.createElement('span'); s.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px'; return s; }); + + toolbar.appendChild(aiBtn); + toolbar.appendChild(sep()); + + toolbar.appendChild(popoutCreateDropdownBtn('Éditer', [ + { label: '🪄 Improve writing', action: () => popoutAction('improve') }, + { label: '🔤 Fix spelling & grammar', action: () => popoutAction('fix-spelling') }, + { label: '📏 Make shorter', action: () => popoutAction('make-shorter') }, + { label: '📐 Make longer', action: () => popoutAction('make-longer') }, + { label: '📋 Simplify language', action: () => popoutAction('simplify') }, + ], 'Modifie le texte sélectionné')); + + toolbar.appendChild(popoutCreateDropdownBtn('Ton', [ + { label: '💼 Professional tone', action: () => popoutAction('tone', { tone: 'professional' }) }, + { label: '💬 Casual tone', action: () => popoutAction('tone', { tone: 'casual' }) }, + ], 'Change le ton du texte')); + + toolbar.appendChild(popoutCreateDropdownBtn('Traduire', [ + { label: '🇬🇧 English', action: () => popoutAction('translate', { target_lang: 'English' }) }, + { label: '🇨🇳 Chinese', action: () => popoutAction('translate', { target_lang: 'Chinese' }) }, + { label: '🇯🇵 Japanese', action: () => popoutAction('translate', { target_lang: 'Japanese' }) }, + { label: '🇩🇪 German', action: () => popoutAction('translate', { target_lang: 'German' }) }, + { label: '🇫🇷 French', action: () => popoutAction('translate', { target_lang: 'French' }) }, + { label: '🇪🇸 Spanish', action: () => popoutAction('translate', { target_lang: 'Spanish' }) }, + ], 'Traduit le texte sélectionné')); + + toolbar.appendChild(sep()); + + toolbar.appendChild(popoutCreateDropdownBtn('Générer', [ + { label: 'ℹ️ Explain this', action: () => popoutAction('explain', {}, 'append') }, + { label: '📝 Summarize', action: () => popoutAction('summarize', {}, 'append') }, + { label: '✏️ Continue writing', action: () => popoutAction('continue', {}, 'append') }, + ], 'Génère du contenu')); + + const rewriteBtn = document.createElement('button'); + rewriteBtn.className = 'ai-toolbar-btn'; + rewriteBtn.innerHTML = '💬 Réécrire'; + rewriteBtn.title = 'Réécrit selon vos instructions'; + Object.assign(rewriteBtn.style, { + fontSize: '0.7rem', background: 'transparent', border: 'none', + cursor: 'pointer', padding: '3px 6px', borderRadius: '4px', color: 'var(--text-primary)', + }); + rewriteBtn.addEventListener('click', async () => { + const v = ev(); if (!v) return; + const text = popoutGetSelection(v); + const instruction = prompt('Instruction de réécriture :', ''); + if (!instruction) return; + try { + const result = await popoutAiAction('rewrite', text, { instruction }); + popoutReplaceSelection(v, result); + popoutShowToast('AI: texte réécrit', 'success'); + } catch (e) { + popoutShowToast('AI: ' + (String(e.message).includes('401') ? 'clé API invalide' : e.message), 'error'); + } + }); + toolbar.appendChild(rewriteBtn); + + toolbar.appendChild(popoutCreateDropdownBtn('🧰 Boîte', [ + { label: '📋 Convert to list', action: () => popoutAction('to-list') }, + { label: '📊 Convert to table', action: () => popoutAction('to-table') }, + { label: '⚙️ Generate frontmatter', action: () => popoutAction('frontmatter', {}, 'before') }, + { label: '🔷 Convert to canvas', action: () => popoutAction('to-canvas', {}, 'append') }, + ], 'Outils de conversion')); + + container.innerHTML = ''; // clear previous + container.appendChild(toolbar); + + // Ctrl+J keyboard shortcut + document.addEventListener('keydown', function popoutAIKey(e) { + if (e.ctrlKey && e.key === 'j') { + const modal = document.getElementById('editor-modal'); + if (!modal || !modal.classList.contains('active')) return; + e.preventDefault(); + aiBtn.click(); + } + }); } async function saveFile() {