feat: AI Editor — toolbar avec menus dropdown, multi-provider (DeepSeek/OpenRouter/Gemini)
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
/* 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';
|
||||
|
||||
// ── 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 the global showToast if available (from ui.js)
|
||||
if (typeof window._showToast === 'function') {
|
||||
window._showToast(msg, type);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dropdown menu builder ──
|
||||
function createMenu(items, parentEl) {
|
||||
// Remove any existing menu
|
||||
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',
|
||||
});
|
||||
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 = 'var(--bg-secondary)';
|
||||
// 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 = '';
|
||||
});
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.action) item.action();
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(el);
|
||||
});
|
||||
|
||||
parentEl.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 = 'var(--bg-secondary)'; });
|
||||
el.addEventListener('mouseleave', () => { el.style.background = ''; });
|
||||
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 function createAIToolbar(container, 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: 'var(--bg-secondary)',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
|
||||
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 = 'AI Inline Completion (Ctrl+J)';
|
||||
aiBtn.addEventListener('click', async () => {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
if (!text.trim()) return;
|
||||
try {
|
||||
const result = await aiAction('inline-complete', text);
|
||||
replaceSelection(v, result, 'append');
|
||||
} catch (e) { showToast('AI: ' + e.message, 'error'); }
|
||||
});
|
||||
|
||||
// ── Edit menu ──
|
||||
const editBtn = createDropdownBtn('Éditer', [
|
||||
{ label: '🪄 Improve writing', action: () => action('improve') },
|
||||
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling') },
|
||||
{ label: '📏 Make shorter', action: () => action('make-shorter') },
|
||||
{ label: '📐 Make longer', action: () => action('make-longer') },
|
||||
{ label: '📋 Simplify language', action: () => action('simplify') },
|
||||
]);
|
||||
|
||||
// ── Tone menu ──
|
||||
const toneBtn = createDropdownBtn('Ton', [
|
||||
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
|
||||
]);
|
||||
|
||||
// ── 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' }) },
|
||||
]);
|
||||
|
||||
// ── Generate menu ──
|
||||
const genBtn = createDropdownBtn('Générer', [
|
||||
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
|
||||
]);
|
||||
|
||||
// ── Custom Rewrite ──
|
||||
const rewriteBtn = document.createElement('button');
|
||||
rewriteBtn.className = 'ai-toolbar-btn';
|
||||
rewriteBtn.innerHTML = '💬 Réécrire';
|
||||
Object.assign(rewriteBtn.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'none',
|
||||
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('Instruction de réécriture :', '');
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const result = await aiAction('rewrite', text, { instruction });
|
||||
replaceSelection(v, result);
|
||||
} catch (e) { showToast('AI: ' + e.message, 'error'); }
|
||||
});
|
||||
|
||||
// ── Toolbox menu ──
|
||||
const toolboxBtn = createDropdownBtn('🧰 Boîte', [
|
||||
{ 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') },
|
||||
]);
|
||||
|
||||
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()) return;
|
||||
try {
|
||||
const result = await aiAction(endpoint, text, extra);
|
||||
replaceSelection(v, result, mode);
|
||||
} catch (e) { showToast('AI: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ai-toolbar-btn';
|
||||
btn.innerHTML = 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 = 'var(--bg-hover)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = ''; });
|
||||
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;
|
||||
}
|
||||
|
||||
// Export showToast for the module
|
||||
export function setToast(fn) { window._showToast = fn; }
|
||||
@@ -2,6 +2,10 @@ import { state } from './state.js';
|
||||
import { api } from './auth.js';
|
||||
import { openFile, showWelcome } from './viewer.js';
|
||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
||||
import { createAIToolbar, setToast } from './ai.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
let _aiToolbarCreated = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
@@ -363,6 +367,16 @@ async function openEditor(vaultName, filePath) {
|
||||
state: cmState,
|
||||
parent: bodyEl,
|
||||
});
|
||||
|
||||
// Set up AI toolbar (once)
|
||||
if (!_aiToolbarCreated) {
|
||||
setToast(showToast);
|
||||
const container = document.getElementById('ai-toolbar-container');
|
||||
if (container) {
|
||||
createAIToolbar(container, () => state.editorView);
|
||||
_aiToolbarCreated = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("CodeMirror init failed, falling back to textarea:", err);
|
||||
state.fallbackEditorEl = document.createElement("textarea");
|
||||
|
||||
Reference in New Issue
Block a user