| |
|
|
| |
|
|
| 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; |
| } |
|
|
| |
|
|
| 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); |
| } |
|
|
| |
| 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; } |
| } |
|
|
| |
|
|
| function buildMarkdownOptions() { |
| const renderer = new marked.Renderer(); |
|
|
| renderer.code = (code, lang) => { |
| const escapedCode = escHtml(code); |
| const displayLang = lang || 'code'; |
| if (lang === 'svg') { |
| |
| return `<div class="svg-render-block" data-svg="${escAttr(code)}"> |
| <img src="data:image/svg+xml,${encodeURIComponent(code)}" style="max-width:100%;cursor:pointer;" alt="SVG"> |
| </div>`; |
| } |
| return `<div class="code-block"> |
| <div class="code-header"> |
| <span class="code-lang">${escHtml(displayLang)}</span> |
| <button class="code-copy-btn" data-code="${escAttr(code)}">Copy</button> |
| </div> |
| <pre><code class="language-${escHtml(displayLang)}">${escapedCode}</code></pre> |
| </div>`; |
| }; |
|
|
| renderer.link = (href, title, text) => { |
| const safeHref = escHtml(href || ''); |
| return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer" title="${escHtml(title || '')}">${text}</a>`; |
| }; |
|
|
| 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 = ` |
| <div id="svg-panel-header"> |
| <span style="font-size:13px;font-weight:600;">SVG Preview</span> |
| <div style="display:flex;gap:6px"> |
| <button id="svg-toggle-btn" style="font-size:11px;padding:3px 8px;border-radius:4px;border:1px solid var(--border-bright);background:var(--bg-hover)">XML</button> |
| <button id="svg-close-btn" style="color:var(--text-muted);font-size:18px;padding:0 4px;">Γ</button> |
| </div> |
| </div> |
| <div id="svg-panel-content"></div>`; |
| 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 = `<img src="data:image/svg+xml,${encodeURIComponent(svgCode)}" style="max-width:100%;" alt="SVG">`; |
| }; |
| renderImg(); |
|
|
| toggleBtn.onclick = () => { |
| showingXml = !showingXml; |
| toggleBtn.textContent = showingXml ? 'Image' : 'XML'; |
| if (showingXml) { |
| content.innerHTML = `<pre style="font-size:12px;white-space:pre-wrap;word-break:break-all;padding:8px;background:var(--bg-raised);border-radius:6px;">${escHtml(svgCode)}</pre>`; |
| } else renderImg(); |
| }; |
| closeBtn.onclick = () => panel.remove(); |
| } |
|
|
| |
|
|
| 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'; |
| } |
|
|
| |
|
|
| export function escHtml(str) { |
| return String(str) |
| .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') |
| .replace(/"/g,'"').replace(/'/g,'''); |
| } |
|
|
| export function escAttr(str) { |
| return String(str).replace(/"/g,'"'); |
| } |
|
|
| window.ui = { showNotification, showContextMenu, showUserMenu, renderMarkdown }; |
|
|