// 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 `
SVG
`; } return `
${escHtml(displayLang)}
${escapedCode}
`; }; renderer.link = (href, title, text) => { const safeHref = escHtml(href || ''); return `${text}`; }; return { renderer, breaks: true, gfm: true }; } export function renderMarkdown(text) { if (!text) return ''; try { marked.setOptions(buildMarkdownOptions()); const raw = marked.parse(text); const clean = DOMPurify.sanitize(raw, { ALLOWED_TAGS: [ 'p','br','strong','em','del','u','s','h1','h2','h3','h4','h5','h6', 'ul','ol','li','blockquote','hr','table','thead','tbody','tr','th','td', 'pre','code','a','img','span','div','details','summary', ], ALLOWED_ATTR: ['href','src','alt','title','class','data-code','data-svg','data-color', 'target','rel','style','open','align'], ALLOW_DATA_ATTR: true, }); return clean; } catch (e) { return escHtml(text); } } export function attachCodeCopyListeners(container) { container.querySelectorAll('.code-copy-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const code = btn.getAttribute('data-code') || ''; try { await navigator.clipboard.writeText(code); btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1400); } catch { btn.textContent = 'Error'; setTimeout(() => btn.textContent = 'Copy', 1400); } }); }); } export function attachSvgPanelListeners(container) { container.querySelectorAll('.svg-render-block img').forEach(img => { img.addEventListener('click', () => { const svgCode = img.closest('.svg-render-block')?.getAttribute('data-svg') || ''; openSvgPanel(svgCode); }); }); } function openSvgPanel(svgCode) { let panel = document.getElementById('svg-panel'); if (!panel) { panel = document.createElement('div'); panel.id = 'svg-panel'; panel.innerHTML = `
SVG Preview
`; document.body.appendChild(panel); } const content = document.getElementById('svg-panel-content'); const toggleBtn = document.getElementById('svg-toggle-btn'); const closeBtn = document.getElementById('svg-close-btn'); let showingXml = false; const renderImg = () => { content.innerHTML = `SVG`; }; renderImg(); toggleBtn.onclick = () => { showingXml = !showingXml; toggleBtn.textContent = showingXml ? 'Image' : 'XML'; if (showingXml) { content.innerHTML = `
${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 };