363 lines
12 KiB
JavaScript
363 lines
12 KiB
JavaScript
/* 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; }
|