/* ObsiGate — AI Editor Toolbar Provides AI-powered text editing features: - Inline completion (Ctrl+J) - Edit (improve, fix spelling, shorter, longer, simplify) - Tone (professional, casual) - Translate (multiple languages) - Generate (explain, summarize, continue) - AI Custom Rewrite - AI Toolbox (to list, to table, frontmatter, to canvas) */ import { api } from './auth.js'; import { t } from './i18n.js'; // ── API call helper ── async function aiAction(endpoint, text, extra = {}) { const body = { text, ...extra }; const data = await api(`/api/ai/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return data.result; } // ── Get selected text from CodeMirror ── function getSelection(editorView) { if (!editorView) return ''; const { from, to } = editorView.state.selection.main; if (from === to) { // No selection — get entire document return editorView.state.doc.toString(); } return editorView.state.doc.sliceString(from, to); } // ── Replace or insert text ── function replaceSelection(editorView, newText, mode = 'replace') { if (!editorView) return; const { from, to } = editorView.state.selection.main; if (mode === 'append') { // Insert at end of selection editorView.dispatch({ changes: { from: to, insert: '\n' + newText }, selection: { anchor: to + 1 + newText.length }, }); } else if (mode === 'before') { editorView.dispatch({ changes: { from: from, insert: newText + '\n' }, }); } else { // Replace selection editorView.dispatch({ changes: { from, to, insert: newText }, selection: { anchor: from + newText.length }, }); } } // ── Show toast notification ── function showToast(msg, type = 'info') { // Use global showToast if available, otherwise console if (typeof window._obsigateShowToast === 'function') { window._obsigateShowToast(msg, type); } } // ── Dropdown menu builder ── function createMenu(items, parentEl) { // Remove any existing menu const existing = document.querySelector('.ai-dropdown-menu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.className = 'ai-dropdown-menu'; // Position relative to parent, but attached to body to avoid // being clipped by overflow:hidden on the editor container. const rect = parentEl.getBoundingClientRect(); const menuTop = rect.bottom + 4; const menuLeft = rect.left; Object.assign(menu.style, { position: 'fixed', top: menuTop + 'px', left: menuLeft + 'px', background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: '6px', padding: '4px 0', minWidth: '200px', maxHeight: '60vh', overflowY: 'auto', zIndex: '10000', boxShadow: '0 4px 16px rgba(0,0,0,0.4)', }); // If menu overflows bottom of viewport, flip above the button requestAnimationFrame(() => { const menuRect = menu.getBoundingClientRect(); if (menuRect.bottom > window.innerHeight - 8) { menu.style.top = Math.max(8, rect.top - menuRect.height - 4) + 'px'; } // If menu overflows right edge, align right if (menuRect.right > window.innerWidth - 8) { menu.style.left = Math.max(8, window.innerWidth - menuRect.width - 8) + 'px'; } }); 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', transition: 'background 0.1s', }); 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); } if (item.children) { const arrow = document.createElement('span'); arrow.style.cssText = 'margin-left:auto;font-size:0.7rem;color:var(--text-muted)'; arrow.textContent = '▶'; el.appendChild(arrow); } el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; // Show submenu if (item.children) { const sub = el.querySelector('.ai-submenu'); if (!sub) { const subMenu = createSubMenu(item.children, el); el.appendChild(subMenu); } } }); el.addEventListener('mouseleave', (e) => { el.style.background = 'transparent'; }); el.addEventListener('click', (e) => { e.stopPropagation(); if (item.action) item.action(); menu.remove(); }); menu.appendChild(el); }); document.body.appendChild(menu); // Close on outside click const closeHandler = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } function createSubMenu(items, parentEl) { const menu = document.createElement('div'); menu.className = 'ai-submenu'; Object.assign(menu.style, { position: 'absolute', left: '100%', top: '0', background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: '6px', padding: '4px 0', minWidth: '180px', zIndex: '101', 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', fontSize: '0.78rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', }); el.innerHTML = item.label; 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(); // Close all menus document.querySelectorAll('.ai-dropdown-menu, .ai-submenu').forEach(m => m.remove()); }); menu.appendChild(el); }); return menu; } // ── Main AI Toolbar ── export async function createAIToolbar(container, getEditorView) { // Check if AI is configured let aiConfigured = false; try { const status = await api('/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 = t('ai.not_configured'); container.appendChild(hint); return; } 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: 'var(--bg-secondary)', flexWrap: 'wrap', position: 'sticky', top: '0', zIndex: '5', }); function ev() { return getEditorView(); } // ── Inline completion 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 = t('ai.inline_completion_title'); aiBtn.addEventListener('click', async () => { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) return; const origHTML = aiBtn.innerHTML; aiBtn.innerHTML = '⏳ AI...'; aiBtn.disabled = true; try { const result = await aiAction('inline-complete', text); replaceSelection(v, result, 'append'); showToast(t('ai.completion_added'), 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error'); } finally { aiBtn.innerHTML = origHTML; aiBtn.disabled = false; } }); // ── Edit menu ── const editBtn = createDropdownBtn(t('ai.edit'), [ { label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') }, { label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' }, { label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' }, { label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') }, { label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' }, ], t('ai.hint_edit_selection')); // ── Tone menu ── const toneBtn = createDropdownBtn('Ton', [ { label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) }, { label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) }, ], 'Change le ton du texte'); // ── Translate menu ── const translateBtn = createDropdownBtn('Traduire', [ { label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) }, { label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) }, { label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) }, { label: '🇩🇪 German', action: () => action('translate', { target_lang: 'German' }) }, { label: '🇫🇷 French', action: () => action('translate', { target_lang: 'French' }) }, { label: '🇪🇸 Spanish', action: () => action('translate', { target_lang: 'Spanish' }) }, ], t('ai.hint_translate')); // ── Generate menu ── const genBtn = createDropdownBtn(t('ai.generate'), [ { label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') }, { label: '📝 Summarize', action: () => action('summarize', {}, 'append') }, { label: '✏️ Continue writing', action: () => action('continue', {}, 'append') }, ], t('ai.hint_generate')); // ── Custom Rewrite ── const rewriteBtn = document.createElement('button'); rewriteBtn.className = 'ai-toolbar-btn'; rewriteBtn.innerHTML = t('ai.rewrite'); rewriteBtn.title = t('ai.rewrite_title'); 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 = getSelection(v); const instruction = prompt(t('ai.rewrite_prompt'), ''); if (!instruction) return; try { const result = await aiAction('rewrite', text, { instruction }); replaceSelection(v, result); showToast(t('ai.text_rewritten'), 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error'); } }); // ── Toolbox menu ── const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [ { label: '📋 Convert to list', action: () => action('to-list') }, { label: '📊 Convert to table', action: () => action('to-table') }, { label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') }, { label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') }, ], 'Outils de conversion'); toolbar.appendChild(aiBtn); toolbar.appendChild(createSeparator()); toolbar.appendChild(editBtn); toolbar.appendChild(toneBtn); toolbar.appendChild(translateBtn); toolbar.appendChild(createSeparator()); toolbar.appendChild(genBtn); toolbar.appendChild(rewriteBtn); toolbar.appendChild(toolboxBtn); container.insertBefore(toolbar, container.firstChild); // ── Action helper ── async function action(endpoint, extra = {}, mode = 'replace') { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) { showToast(t('ai.select_text'), 'warning'); return; } showToast(t('ai.processing'), 'info'); // Show processing indicator in the editor header _showProcessingIndicator(true); // Dispatch AI processing start event document.dispatchEvent(new CustomEvent('ai-processing-start', { detail: { endpoint, mode } })); try { const result = await aiAction(endpoint, text, extra); replaceSelection(v, result, mode); showToast(t('ai.text_processed'), 'success'); } catch (e) { const msg = String(e.message || e); if (msg.includes('401')) { showToast(t('ai.invalid_api_key'), 'error'); } else if (msg.includes('402') || msg.includes('429')) { showToast(t('ai.quota_exceeded'), 'error'); } else { showToast('AI: ' + msg, 'error'); } } finally { _showProcessingIndicator(false); // Dispatch AI processing end event document.dispatchEvent(new CustomEvent('ai-processing-end')); } } // ── In-editor processing indicator ── function _showProcessingIndicator(show) { let el = document.getElementById('ai-processing-indicator'); if (show) { if (!el) { el = document.createElement('span'); el.id = 'ai-processing-indicator'; el.className = 'ai-processing-indicator'; el.innerHTML = ' AI...'; el.title = 'Traitement AI en cours'; const header = document.querySelector('#editor-modal .editor-header'); if (header) header.appendChild(el); } el.style.display = 'inline-flex'; } else if (el) { el.style.display = 'none'; } } // ── Keyboard shortcut Ctrl+J for inline completion ── document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'j') { const modal = document.getElementById('editor-modal'); if (!modal || !modal.classList.contains('active')) return; e.preventDefault(); aiBtn.click(); } }); return toolbar; } function createDropdownBtn(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(); createMenu(items, btn); }); btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.08)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); return btn; } function createSeparator() { const sep = document.createElement('span'); sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px'; return sep; }