// ui.js — notifications, context menus, markdown, shared DOM helpers // ── Notifications ───────────────────────────────────────────────────────── export function showNotification({ type = 'info', message, action, duration = 5000 }) { const container = document.getElementById('notifications'); if (!container) return; const el = document.createElement('div'); el.className = `notification ${type}`; const text = document.createElement('span'); text.style.flex = '1'; text.textContent = message; el.appendChild(text); if (action) { const btn = document.createElement('button'); btn.textContent = action.label; btn.style.cssText = 'margin-left:10px;color:var(--yellow);font-size:12px;font-weight:600;white-space:nowrap;'; btn.addEventListener('click', () => { action.onClick?.(); el.remove(); }); el.appendChild(btn); } const close = document.createElement('button'); close.className = 'notif-close'; close.textContent = '×'; close.addEventListener('click', () => el.remove()); el.appendChild(close); container.appendChild(el); if (duration > 0) setTimeout(() => el.remove(), duration); return el; } // ── Context menu ────────────────────────────────────────────────────────── let activeMenu = null; let menuDocHandler = null; export function showContextMenu(x, y, items) { closeContextMenu(); const menu = document.getElementById('session-context-menu'); menu.innerHTML = ''; for (const item of items) { if (item.separator) { const sep = document.createElement('div'); sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; menu.appendChild(sep); continue; } const el = document.createElement('div'); el.className = 'context-item' + (item.danger ? ' danger' : '') + (item.warning ? ' warning' : ''); if (item.icon) { const ic = document.createElement('span'); ic.textContent = item.icon; ic.style.fontSize = '14px'; el.appendChild(ic); } const label = document.createElement('span'); label.textContent = item.label; el.appendChild(label); el.addEventListener('click', (e) => { e.stopPropagation(); closeContextMenu(); item.onClick?.(); }); menu.appendChild(el); } // Position menu.classList.remove('hidden'); const rect = menu.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; let left = x, top = y; if (left + rect.width > vw - 8) left = vw - rect.width - 8; if (top + rect.height > vh - 8) top = y - rect.height; if (top < 8) top = 8; menu.style.left = `${left}px`; menu.style.top = `${top}px`; activeMenu = menu; queueMicrotask(() => { menuDocHandler = (e) => { if (!menu.contains(e.target)) closeContextMenu(); }; document.addEventListener('click', menuDocHandler); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeContextMenu(); }, { once: true }); }); } export function showUserMenu(x, y, items) { const menu = document.getElementById('user-context-menu'); menu.innerHTML = ''; for (const item of items) { const el = document.createElement('div'); el.className = 'context-item' + (item.danger ? ' danger' : ''); el.textContent = item.label; el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick?.(); }); menu.appendChild(el); } menu.classList.remove('hidden'); const vw = window.innerWidth, vh = window.innerHeight; const rect = menu.getBoundingClientRect(); let left = x, top = y; if (left + rect.width > vw - 8) left = vw - rect.width - 8; if (top + rect.height > vh - 8) top = y - rect.height; menu.style.left = `${left}px`; menu.style.top = `${top}px`; setTimeout(() => { document.addEventListener('click', (e) => { if (!menu.contains(e.target)) menu.classList.add('hidden'); }, { once: true }); }, 0); } export function closeContextMenu() { activeMenu?.classList.add('hidden'); activeMenu = null; if (menuDocHandler) { document.removeEventListener('click', menuDocHandler); menuDocHandler = null; } } // ── Markdown rendering ──────────────────────────────────────────────────── function buildMarkdownOptions() { const renderer = new marked.Renderer(); renderer.code = (code, lang) => { const escapedCode = escHtml(code); const displayLang = lang || 'code'; if (lang === 'svg') { // SVG inline render return `
${escapedCode}
${escHtml(svgCode)}`;
} else renderImg();
};
closeBtn.onclick = () => panel.remove();
}
// ── Textarea auto-resize ───────────────────────────────────────────────────
export function autoResize(textarea, maxLines = 6) {
const lh = parseFloat(getComputedStyle(textarea).lineHeight) || 22;
const pad = parseFloat(getComputedStyle(textarea).paddingTop || '0')
+ parseFloat(getComputedStyle(textarea).paddingBottom || '0');
textarea.style.height = 'auto';
const max = lh * maxLines + pad;
textarea.style.height = Math.min(textarea.scrollHeight, max) + 'px';
textarea.style.overflowY = textarea.scrollHeight > max ? 'auto' : 'hidden';
}
// ── Escape helpers ────────────────────────────────────────────────────────
export function escHtml(str) {
return String(str)
.replace(/&/g,'&').replace(//g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
export function escAttr(str) {
return String(str).replace(/"/g,'"');
}
window.ui = { showNotification, showContextMenu, showUserMenu, renderMarkdown };