| |
| |
| |
| |
| |
| |
|
|
|
|
| import uiModule from './ui.js'; |
| import sessionModule from './sessions.js'; |
| import emojiPicker from './emojiPicker.js'; |
| import markdownModule from './markdown.js'; |
| import codeRunnerModule from './codeRunner.js'; |
| import { langIcon } from './langIcons.js'; |
| import spinnerModule from './spinner.js'; |
| import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js'; |
| import signatureModule from './signature.js'; |
| import * as Modals from './modalManager.js'; |
|
|
| let API_BASE = ''; |
| let isOpen = false; |
| let _hlDebounce = null; |
| let _isEditingTabTitle = false; |
| let _autoDetectDebounce = null; |
| let _autoTitleDebounce = null; |
| let _autoSaveDebounce = null; |
| let _animationInProgress = false; |
| let _animationCancel = null; |
| let _htmlPreviewActive = false; |
| let _emailAccountsCache = null; |
| let _emailAccountsCacheAt = 0; |
| let _emailHeaderManualExpandUntil = 0; |
|
|
| |
| let _diffModeActive = false; |
| let _diffOldContent = null; |
| let _diffNewContent = null; |
| let _diffChunks = []; |
| let _diffUnresolvedCount = 0; |
|
|
| |
| const AUTO_DETECT_DELAY = 500; |
| const AUTO_DETECT_MIN_CHARS = 30; |
| const AUTO_DETECT_MIN_RELEVANCE = 8; |
| const AUTO_DETECT_SAMPLE_SIZE = 2000; |
| const HLJS_TO_DROPDOWN = { |
| python: 'python', javascript: 'javascript', typescript: 'typescript', |
| xml: 'html', html: 'html', css: 'css', markdown: 'markdown', |
| json: 'json', yaml: 'yaml', bash: 'bash', shell: 'bash', |
| sql: 'sql', rust: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', |
| csv: 'csv', |
| }; |
|
|
| |
| |
| |
| |
| const _isRenderLang = (l) => ['html', 'svg', 'xml'].includes((l || '').toLowerCase()); |
| |
| |
| |
| |
| const _hasViewToggle = (l) => { |
| const lang = (l || '').toLowerCase(); |
| return [ |
| 'csv', 'python', 'javascript', 'typescript', 'bash', 'sh', 'shell', |
| 'php', 'ruby', 'sql', 'java', 'go', 'rust', |
| 'c', 'cpp', 'c++', 'csharp', 'c#', |
| 'yaml', 'json', 'css', |
| 'ini', 'toml', |
| ].includes(lang) || _isRenderLang(lang); |
| }; |
|
|
| async function _getEmailAccountsCached() { |
| const now = Date.now(); |
| if (_emailAccountsCache && (now - _emailAccountsCacheAt) < 30000) return _emailAccountsCache; |
| try { |
| const res = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' }); |
| if (!res.ok) throw new Error('accounts failed'); |
| const data = await res.json(); |
| _emailAccountsCache = Array.isArray(data.accounts) ? data.accounts : []; |
| } catch (_) { |
| _emailAccountsCache = []; |
| } |
| _emailAccountsCacheAt = now; |
| return _emailAccountsCache; |
| } |
|
|
| function _accountCanSend(account) { |
| return !!(account && account.smtp_host && account.smtp_user && account.has_smtp_password); |
| } |
|
|
| async function _resolveComposeSendAccountId() { |
| const activeAccountId = window.__odysseusActiveEmailAccount || null; |
| if (!activeAccountId) return null; |
| const accounts = await _getEmailAccountsCached(); |
| const activeAccount = accounts.find(a => String(a.id) === String(activeAccountId)); |
| if (!activeAccount || _accountCanSend(activeAccount)) return activeAccountId; |
| if (uiModule) uiModule.showToast('Selected email account is receive-only; using your SMTP account.'); |
| return null; |
| } |
|
|
| |
| { |
| const s = document.createElement('style'); |
| s.id = 'doc-tab-menu-styles'; |
| s.textContent = `.doc-tab-menu-btn{background:none!important;border:none!important;outline:none!important;box-shadow:none!important;color:var(--fg);opacity:0.25;cursor:pointer;padding:2px 4px!important;height:auto!important;line-height:1;transition:opacity .15s;flex-shrink:0;-webkit-appearance:none;appearance:none}.doc-tab-menu-btn:focus,.doc-tab-menu-btn:active{outline:none!important;box-shadow:none!important;background:none!important}.doc-tab:hover .doc-tab-menu-btn{opacity:.5}.doc-tab-menu-btn:hover{opacity:1!important}.doc-tab-dropdown .dropdown-item-compact{padding:6px 8px;border-radius:6px;cursor:pointer;white-space:nowrap;border-bottom:none;display:flex;align-items:center;gap:10px;font-size:11px}.doc-tab-dropdown .dropdown-item-compact:hover{background:color-mix(in srgb,var(--fg) 8%,transparent)}.doc-tab-dropdown .dropdown-item-compact .dropdown-icon{width:14px;height:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;opacity:0.5}.doc-tab-dropdown .dropdown-divider{height:1px;margin:3px 0;background:color-mix(in srgb,var(--border) 40%,transparent)}.doc-tab-action-delete{color:var(--red,#e06c75)!important}.doc-tab-action-delete .dropdown-icon{opacity:0.7!important}`; |
| document.head.appendChild(s); |
| } |
|
|
| |
| let activeDocId = null; |
| let _lastSessionId = ''; |
| const docs = new Map(); |
|
|
| const _docOpenKey = (sessionId) => 'odysseus-doc-open-' + sessionId; |
| const _docMinimizedKey = (sessionId) => 'odysseus-doc-minimized-' + sessionId; |
|
|
| function _markDocVisibleState(sessionId, state) { |
| if (!sessionId) return; |
| if (state === 'open') { |
| localStorage.setItem(_docOpenKey(sessionId), '1'); |
| localStorage.removeItem(_docMinimizedKey(sessionId)); |
| } else if (state === 'minimized') { |
| localStorage.removeItem(_docOpenKey(sessionId)); |
| localStorage.setItem(_docMinimizedKey(sessionId), '1'); |
| } else { |
| localStorage.removeItem(_docOpenKey(sessionId)); |
| localStorage.removeItem(_docMinimizedKey(sessionId)); |
| } |
| } |
|
|
| |
| function _ensureAgentMode() { |
| const ab = document.getElementById('mode-agent-btn'); |
| const cb = document.getElementById('mode-chat-btn'); |
| if (ab && !ab.classList.contains('active')) { |
| ab.click(); |
| } |
| } |
|
|
| export function init(apiBase) { |
| API_BASE = apiBase; |
| initLibrary({ |
| apiBase, |
| esc: _esc, |
| getDocs: () => docs, |
| isOpen: () => isOpen, |
| createDocument, |
| loadDocument, |
| switchToDoc, |
| openPanel, |
| addDocToTabs, |
| syncDocIndicator: _syncDocIndicator, |
| }); |
| _maybeOpenDocFromHash(); |
| window.addEventListener('hashchange', _maybeOpenDocFromHash); |
| } |
|
|
| |
| function _syncDocIndicator() { |
| const btn = document.getElementById('overflow-doc-btn'); |
| |
| const hasDocs = docs.size > 0; |
| if (btn) btn.classList.toggle('has-docs', hasDocs); |
| |
| const indicator = document.getElementById('doc-indicator-btn'); |
| if (indicator) indicator.classList.toggle('visible', hasDocs); |
| |
| if (btn) btn.style.display = hasDocs ? 'none' : ''; |
| |
| const sid = sessionModule?.getCurrentSessionId(); |
| if (sid && sessionModule.setSessionHasDocs) { |
| sessionModule.setSessionHasDocs(sid, hasDocs); |
| } |
| } |
|
|
| |
|
|
| function updateArrowVisibility(scrollArea, leftBtn, rightBtn) { |
| const atLeft = scrollArea.scrollLeft <= 0; |
| const atRight = scrollArea.scrollLeft + scrollArea.clientWidth >= scrollArea.scrollWidth - 1; |
| leftBtn.style.display = atLeft ? 'none' : ''; |
| rightBtn.style.display = atRight ? 'none' : ''; |
| |
| |
| |
| scrollArea.classList.toggle('is-at-left', atLeft); |
| scrollArea.classList.toggle('is-at-right', atRight); |
| } |
|
|
| |
| |
| |
| |
| function _wireSwipeDismiss(el) { |
| if (!el) return; |
| const DISMISS_THRESHOLD = 50; |
| const VELOCITY_THRESHOLD = 0.3; |
| const RUBBER_RESISTANCE = 0.35; |
| let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; |
| let dragging = false, cancelled = false; |
| const getPane = () => document.getElementById('doc-editor-pane'); |
| let pane = null; |
|
|
| el.addEventListener('touchstart', (e) => { |
| if (window.innerWidth > 768 || e.touches.length !== 1) return; |
| pane = getPane(); |
| if (!pane) return; |
| const t = e.touches[0]; |
| startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; |
| velocity = 0; dragging = false; cancelled = false; |
| }, { passive: true }); |
|
|
| el.addEventListener('touchmove', (e) => { |
| if (cancelled || !pane || window.innerWidth > 768) return; |
| const t = e.touches[0]; |
| const dx = Math.abs(t.clientX - startX); |
| const dy = t.clientY - startY; |
| if (!dragging) { |
| if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } |
| if (Math.abs(dy) > 8) { |
| dragging = true; |
| |
| |
| pane.style.animation = 'none'; |
| pane.style.transition = 'none'; |
| pane.style.willChange = 'transform'; |
| } else return; |
| } |
| const dt = e.timeStamp - lastT; |
| if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; |
| lastY = t.clientY; lastT = e.timeStamp; |
| e.preventDefault(); |
| pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER_RESISTANCE}px)`; |
| }, { passive: false }); |
|
|
| const endSwipe = () => { |
| if (!dragging || !pane) { pane = null; return; } |
| const p = pane; pane = null; dragging = false; |
| p.style.willChange = ''; |
| const dy = lastY - startY; |
| const shouldDismiss = dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD); |
| if (shouldDismiss) { |
| closePanel('down'); |
| } else { |
| p.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; |
| p.style.transform = ''; |
| setTimeout(() => { p.style.transition = ''; }, 260); |
| } |
| }; |
| el.addEventListener('touchend', endSwipe, { passive: true }); |
| el.addEventListener('touchcancel', endSwipe, { passive: true }); |
| } |
|
|
| function renderTabs() { |
| if (_isEditingTabTitle) return; |
| const tabBar = document.getElementById('doc-tab-bar'); |
| if (!tabBar) return; |
|
|
| |
| |
| const paneEl = document.querySelector('.doc-editor-pane'); |
| const isDocLeft = paneEl && paneEl.classList.contains('doc-left'); |
| let html = ''; |
| html += '<button class="doc-tab-arrow doc-tab-arrow-left" id="doc-tab-left" title="Scroll left">‹</button>'; |
| html += '<div class="doc-tab-scroll" id="doc-tab-scroll">'; |
| const curSession = sessionModule?.getCurrentSessionId() || ''; |
| let _anyTab = false; |
| for (const [id, doc] of docs) { |
| |
| if (doc.sessionId && curSession && doc.sessionId !== curSession) continue; |
| _anyTab = true; |
| const isActive = id === activeDocId; |
| const title = doc.title || 'Untitled'; |
| const shortTitle = title.length > 24 ? title.slice(0, 22) + '...' : title; |
| const menuBtn = `<button class="doc-tab-menu-btn" data-doc-id="${id}" title="Document actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="19" r="2.5"/></svg></button>`; |
| const ver = doc.version || doc.version_count || 1; |
| const verChip = `<span class="doc-tab-version" data-doc-id="${id}" title="Version history">v${ver}</span>`; |
| |
| |
| const lic = (doc.language && doc.language !== 'text') |
| ? langIcon(doc.language, 12, { style: 'opacity:0.65;flex-shrink:0;color:currentColor;margin-right:4px;' }) |
| : ''; |
| const langChip = `<span class="doc-tab-lang">${lic}</span>`; |
| html += `<div class="doc-tab${isActive ? ' active' : ''}" draggable="true" data-doc-id="${id}" title="${title}"> |
| ${verChip}${langChip}<span class="doc-tab-title">${shortTitle}</span> |
| <button class="doc-tab-close" data-doc-id="${id}" title="Unlink from chat (kept in the Library)">×</button> |
| </div>`; |
| } |
| |
| |
| if (!_anyTab && isOpen && !activeDocId) { |
| html += `<div class="doc-tab active doc-tab-ghost" title="New document — start typing"><span class="doc-tab-title">Untitled</span></div>`; |
| } |
| html += `<button class="doc-tab-new" id="doc-tab-new-btn" title="New document"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>`; |
| html += '</div>'; |
| html += '<button class="doc-tab-arrow doc-tab-arrow-right" id="doc-tab-right" title="Scroll right">›</button>'; |
| tabBar.innerHTML = html; |
|
|
| |
| const scrollArea = document.getElementById('doc-tab-scroll'); |
| const leftBtn = document.getElementById('doc-tab-left'); |
| const rightBtn = document.getElementById('doc-tab-right'); |
| if (scrollArea && leftBtn && rightBtn) { |
| leftBtn.addEventListener('click', () => scrollArea.scrollBy({ left: -120, behavior: 'smooth' })); |
| rightBtn.addEventListener('click', () => scrollArea.scrollBy({ left: 120, behavior: 'smooth' })); |
| updateArrowVisibility(scrollArea, leftBtn, rightBtn); |
| scrollArea.addEventListener('scroll', () => updateArrowVisibility(scrollArea, leftBtn, rightBtn)); |
| } |
|
|
| |
| if (!tabBar._swipeWired) { tabBar._swipeWired = true; _wireSwipeDismiss(tabBar); } |
|
|
| |
| |
| |
| |
| const _scrollTabIntoView = (tab, behavior = 'smooth') => { |
| const sa = document.getElementById('doc-tab-scroll'); |
| if (!sa || !tab) return; |
| const EDGE_PAD = 30; |
| const tabLeft = tab.offsetLeft; |
| const tabRight = tabLeft + tab.offsetWidth; |
| const visLeft = sa.scrollLeft + EDGE_PAD; |
| const visRight = sa.scrollLeft + sa.clientWidth - EDGE_PAD; |
| if (tabRight > visRight) { |
| sa.scrollTo({ left: sa.scrollLeft + tabRight - visRight, behavior }); |
| } else if (tabLeft < visLeft) { |
| sa.scrollTo({ left: Math.max(0, sa.scrollLeft + tabLeft - visLeft), behavior }); |
| } |
| }; |
| |
| let _tabClickTimer = null; |
| tabBar.querySelectorAll('.doc-tab').forEach(tab => { |
| tab.addEventListener('click', (e) => { |
| |
| if (e.target.closest('.doc-tab-close') || e.target.closest('.doc-tab-play') || e.target.closest('.doc-tab-menu-btn') || e.target.closest('.doc-tab-version')) return; |
| if (_isEditingTabTitle) return; |
| |
| if (e.target.classList.contains('doc-tab-title')) { |
| clearTimeout(_tabClickTimer); |
| _tabClickTimer = setTimeout(() => { switchToDoc(tab.dataset.docId); _scrollTabIntoView(tab); }, 250); |
| } else { |
| switchToDoc(tab.dataset.docId); |
| _scrollTabIntoView(tab); |
| } |
| }); |
| tab.addEventListener('dblclick', (e) => { |
| clearTimeout(_tabClickTimer); |
| const titleSpan = tab.querySelector('.doc-tab-title'); |
| if (!titleSpan) return; |
| e.stopPropagation(); |
| const docId = tab.dataset.docId; |
| const doc = docs.get(docId); |
| if (!doc) return; |
| startTitleEdit(titleSpan, docId, doc); |
| }); |
| }); |
|
|
| |
| |
| if (tabBar._closeHandler) tabBar.removeEventListener('click', tabBar._closeHandler); |
| tabBar._closeHandler = (e) => { |
| const verBtn = e.target.closest('.doc-tab-version'); |
| if (verBtn) { |
| e.stopPropagation(); |
| const docId = verBtn.dataset.docId; |
| if (docId) { if (docId !== activeDocId) switchToDoc(docId); toggleVersionHistory(); } |
| return; |
| } |
| const playBtn = e.target.closest('.doc-tab-play'); |
| if (playBtn) { |
| e.stopPropagation(); |
| const docId = playBtn.dataset.docId; |
| if (docId) { |
| if (docId !== activeDocId) switchToDoc(docId); |
| toggleHtmlPreview(); |
| } |
| return; |
| } |
| const menuBtnEl = e.target.closest('.doc-tab-menu-btn'); |
| if (menuBtnEl) { |
| e.stopPropagation(); |
| const docId = menuBtnEl.dataset.docId; |
| if (docId) showDocTabMenu(menuBtnEl, docId); |
| return; |
| } |
| const closeBtn = e.target.closest('.doc-tab-close'); |
| if (!closeBtn) return; |
| e.stopPropagation(); |
| const docId = closeBtn.dataset.docId; |
| if (docId) closeTab(docId); |
| }; |
| tabBar.addEventListener('click', tabBar._closeHandler); |
|
|
| |
| initTabDragReorder(tabBar); |
|
|
| |
| const newBtn = document.getElementById('doc-tab-new-btn'); |
| if (newBtn) { |
| newBtn.addEventListener('click', async () => { |
| let sessionId = docs.get(activeDocId)?.sessionId |
| || _lastSessionId |
| || (sessionModule && sessionModule.getCurrentSessionId()); |
| if (!sessionId) { |
| try { |
| sessionId = await _autoCreateSession(); |
| } catch (e) { |
| console.error('Failed to auto-create session for document:', e); |
| return; |
| } |
| } |
| createDocument(sessionId); |
| }); |
| } |
|
|
| |
| requestAnimationFrame(() => { |
| const at = document.getElementById('doc-tab-scroll')?.querySelector('.doc-tab.active'); |
| _scrollTabIntoView(at, 'auto'); |
| }); |
| } |
|
|
| |
| function startTitleEdit(titleSpan, docId, doc) { |
| if (_isEditingTabTitle) return; |
| _isEditingTabTitle = true; |
|
|
| const fullTitle = doc.title || ''; |
| const input = document.createElement('input'); |
| input.type = 'text'; |
| input.className = 'doc-tab-title-input'; |
| input.value = fullTitle; |
|
|
| titleSpan.replaceWith(input); |
| input.focus(); |
| input.select(); |
|
|
| function commitEdit() { |
| if (!_isEditingTabTitle) return; |
| const newTitle = input.value.trim(); |
| _isEditingTabTitle = false; |
| doc.title = newTitle; |
| if (docId === activeDocId) { |
| const titleInput = document.getElementById('doc-title-input'); |
| if (titleInput) titleInput.value = newTitle; |
| } |
| updateTitle(docId, newTitle); |
| renderTabs(); |
| } |
|
|
| function cancelEdit() { |
| _isEditingTabTitle = false; |
| renderTabs(); |
| } |
|
|
| input.addEventListener('blur', commitEdit); |
| input.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| input.removeEventListener('blur', commitEdit); |
| commitEdit(); |
| } else if (e.key === 'Escape') { |
| e.preventDefault(); |
| input.removeEventListener('blur', commitEdit); |
| cancelEdit(); |
| } |
| }); |
| } |
|
|
| |
| function initTabDragReorder(tabBar) { |
| let dragId = null; |
|
|
| tabBar.querySelectorAll('.doc-tab').forEach(tab => { |
| tab.addEventListener('dragstart', (e) => { |
| dragId = tab.dataset.docId; |
| tab.classList.add('dragging'); |
| e.dataTransfer.effectAllowed = 'move'; |
| }); |
|
|
| tab.addEventListener('dragend', () => { |
| tab.classList.remove('dragging'); |
| dragId = null; |
| tabBar.querySelectorAll('.doc-tab').forEach(t => t.classList.remove('drag-over')); |
| }); |
|
|
| tab.addEventListener('dragover', (e) => { |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = 'move'; |
| if (tab.dataset.docId !== dragId) { |
| tab.classList.add('drag-over'); |
| } |
| }); |
|
|
| tab.addEventListener('dragleave', () => { |
| tab.classList.remove('drag-over'); |
| }); |
|
|
| tab.addEventListener('drop', (e) => { |
| e.preventDefault(); |
| tab.classList.remove('drag-over'); |
| const targetId = tab.dataset.docId; |
| if (!dragId || dragId === targetId) return; |
|
|
| |
| const entries = [...docs.entries()]; |
| const fromIdx = entries.findIndex(([k]) => k === dragId); |
| const toIdx = entries.findIndex(([k]) => k === targetId); |
| if (fromIdx === -1 || toIdx === -1) return; |
|
|
| const [moved] = entries.splice(fromIdx, 1); |
| entries.splice(toIdx, 0, moved); |
|
|
| docs.clear(); |
| for (const [k, v] of entries) docs.set(k, v); |
|
|
| renderTabs(); |
| }); |
| }); |
| } |
|
|
| |
| function showEmptyState() { |
| activeDocId = null; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const langSelect = document.getElementById('doc-language-select'); |
| const badge = document.getElementById('doc-version-badge'); |
|
|
| if (textarea) textarea.value = ''; |
| if (textarea) textarea.placeholder = 'Start typing or paste text to create a document...'; |
| if (textarea) textarea.disabled = false; |
| if (langSelect) langSelect.value = ''; |
| if (badge) badge.textContent = ''; |
| _hideLoadingOverlay(); |
| syncHighlighting(); |
| renderTabs(); |
| } |
|
|
| let _loadingSpinner = null; |
| function _showLoadingOverlay() { |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (!wrap) return; |
| let overlay = wrap.querySelector('.doc-loading-overlay'); |
| if (!overlay) { |
| overlay = document.createElement('div'); |
| overlay.className = 'doc-loading-overlay'; |
| wrap.appendChild(overlay); |
| } |
| overlay.innerHTML = ''; |
| overlay.style.display = ''; |
| _loadingSpinner = spinnerModule.create('', 'clean', 'whirlpool'); |
| const el = _loadingSpinner.createElement(); |
| overlay.appendChild(el); |
| _loadingSpinner.start(); |
| } |
|
|
| function _hideLoadingOverlay() { |
| if (_loadingSpinner) { _loadingSpinner.destroy(); _loadingSpinner = null; } |
| const overlay = document.querySelector('.doc-loading-overlay'); |
| if (overlay) overlay.style.display = 'none'; |
| } |
|
|
| |
| function _isFormBackedDoc(content) { |
| const c = content || ''; |
| return /<!--\s*pdf_form_source\s+upload_id="[^"]+"/.test(c) |
| || /<!--\s*pdf_source\s+upload_id="[^"]+"/.test(c); |
| } |
|
|
| |
| |
| |
| function _dismissDocKb() { |
| if (!(('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0)) return; |
| const ta = document.getElementById('doc-editor-textarea'); |
| const ae = document.activeElement; |
| const el = (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) ? ae : ta; |
| if (!el) return; |
| try { |
| el.setAttribute('readonly', 'readonly'); |
| el.blur(); |
| setTimeout(() => { try { el.removeAttribute('readonly'); } catch (_) {} }, 120); |
| } catch (_) { try { el.blur(); } catch (_) {} } |
| } |
|
|
| async function _downloadFilledPdf() { |
| if (!activeDocId) return; |
| _dismissDocKb(); |
| await _saveActiveDocBeforeExport(); |
| try { |
| const r = await fetch(`${API_BASE}/api/document/${activeDocId}/export-pdf`); |
| if (!r.ok) { |
| const t = await r.text(); |
| throw new Error(t || r.statusText); |
| } |
| const blob = await r.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| const cd = r.headers.get('Content-Disposition') || ''; |
| const m = cd.match(/filename\*?=(?:UTF-8'')?"?([^"';]+)/i); |
| const _slug = (s) => (s || 'form').replace(/\.pdf$/i, '').replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '') || 'form'; |
| a.download = (m && decodeURIComponent(m[1])) || (_slug(docs.get(activeDocId)?.title) + '_annotated.pdf'); |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| setTimeout(() => URL.revokeObjectURL(url), 1000); |
| } catch (e) { |
| if (uiModule) uiModule.showError('Export failed: ' + e.message); |
| else alert('Export failed: ' + e.message); |
| } |
| } |
|
|
| async function _saveActiveDocBeforeExport() { |
| |
| |
| |
| |
| |
| |
| if (_pdfPaneSaveTimer) { |
| clearTimeout(_pdfPaneSaveTimer); |
| await _savePdfPaneToMarkdown(); |
| } |
| const ta = document.getElementById('doc-editor-textarea'); |
| const doc = docs.get(activeDocId); |
| if (!ta || !doc || !activeDocId) return; |
| const live = ta.value; |
| if (live === doc.content) return; |
| try { |
| await fetch(`${API_BASE}/api/document/${activeDocId}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ content: live }), |
| }); |
| doc.content = live; |
| } catch (e) { |
| console.warn('Pre-export save failed:', e); |
| } |
| } |
|
|
| async function _openExportPdfModal() { |
| if (!activeDocId) return; |
| await _saveActiveDocBeforeExport(); |
|
|
| const overlay = document.createElement('div'); |
| overlay.className = 'modal pdf-export-overlay'; |
| overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);'; |
| overlay.innerHTML = ` |
| <div class="modal-content" style="width:min(780px,94vw);max-height:86vh;"> |
| <div class="modal-header"> |
| <h4>Export filled PDF</h4> |
| <button id="pdf-export-close" class="modal-close" title="Close">×</button> |
| </div> |
| <div id="pdf-export-summary" style="font-size:0.78rem;opacity:0.7;margin:0 0 6px;">Loading field values…</div> |
| <div id="pdf-export-body" class="modal-body" style="font-size:0.85rem;"> |
| <div style="opacity:0.6;">Fetching mapping…</div> |
| </div> |
| <div class="modal-footer" style="display:flex;justify-content:flex-end;gap:8px;padding-top:8px;border-top:1px solid var(--border);margin-top:6px;align-items:center;"> |
| <span id="pdf-export-status" style="font-size:0.75rem;opacity:0.7;margin-right:auto;"></span> |
| <button id="pdf-export-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button> |
| <button id="pdf-export-download" class="confirm-btn confirm-btn-primary" disabled>Download PDF</button> |
| </div> |
| </div> |
| `; |
| document.body.appendChild(overlay); |
|
|
| const close = () => overlay.remove(); |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); |
| overlay.querySelector('#pdf-export-close').addEventListener('click', close); |
| overlay.querySelector('#pdf-export-cancel').addEventListener('click', close); |
|
|
| let fields = []; |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}/export-pdf/preview`, { method: 'POST' }); |
| if (!res.ok) { |
| const err = await res.text(); |
| throw new Error(err || res.statusText); |
| } |
| const data = await res.json(); |
| fields = data.fields || []; |
|
|
| const filledNow = data.filled || 0; |
| const total = data.total || fields.length; |
| overlay.querySelector('#pdf-export-summary').textContent = |
| `${filledNow} of ${total} fields filled. Review and adjust below before downloading.`; |
|
|
| const body = overlay.querySelector('#pdf-export-body'); |
| body.innerHTML = ''; |
|
|
| |
| const byPage = new Map(); |
| for (const f of fields) { |
| const p = f.page || 1; |
| if (!byPage.has(p)) byPage.set(p, []); |
| byPage.get(p).push(f); |
| } |
| const pages = Array.from(byPage.keys()).sort((a, b) => a - b); |
|
|
| |
| const jumpBar = document.createElement('div'); |
| jumpBar.style.cssText = 'position:sticky;top:0;background:var(--panel);padding:6px 0;margin-bottom:8px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:0.72rem;z-index:1;'; |
| jumpBar.innerHTML = '<span style="opacity:0.6;margin-right:4px;">Jump to:</span>'; |
| const pageAnchors = {}; |
| const _smallBtnClass = 'confirm-btn confirm-btn-secondary'; |
| const _smallBtnStyle = 'padding:2px 8px;font-size:0.72rem;'; |
| for (const p of pages) { |
| const a = document.createElement('button'); |
| a.textContent = String(p); |
| a.title = `Jump to page ${p}`; |
| a.className = _smallBtnClass; |
| a.style.cssText = _smallBtnStyle; |
| a.addEventListener('click', () => pageAnchors[p]?.scrollIntoView({ behavior: 'smooth', block: 'start' })); |
| jumpBar.appendChild(a); |
| } |
| const sep = document.createElement('span'); |
| sep.style.cssText = 'opacity:0.4;margin:0 4px;'; |
| sep.textContent = '|'; |
| jumpBar.appendChild(sep); |
| const topBtn = document.createElement('button'); |
| topBtn.textContent = '↑ Top'; |
| topBtn.className = _smallBtnClass; |
| topBtn.style.cssText = _smallBtnStyle; |
| topBtn.addEventListener('click', () => body.scrollTo({ top: 0, behavior: 'smooth' })); |
| jumpBar.appendChild(topBtn); |
| const botBtn = document.createElement('button'); |
| botBtn.textContent = '↓ Bottom'; |
| botBtn.title = 'Jump to the last page (signature fields are usually here)'; |
| botBtn.className = _smallBtnClass; |
| botBtn.style.cssText = _smallBtnStyle; |
| botBtn.addEventListener('click', () => body.scrollTo({ top: body.scrollHeight, behavior: 'smooth' })); |
| jumpBar.appendChild(botBtn); |
| body.appendChild(jumpBar); |
|
|
| for (const p of pages) { |
| const sec = document.createElement('div'); |
| sec.className = 'pdf-export-section'; |
| sec.id = `pdf-export-page-${p}`; |
| pageAnchors[p] = sec; |
| sec.innerHTML = `<div class="pdf-export-section-title">Page ${p}</div>`; |
| for (const f of byPage.get(p)) { |
| const row = document.createElement('div'); |
| row.className = 'pdf-export-row'; |
| const label = document.createElement('label'); |
| label.textContent = f.label || f.name; |
| label.title = `${f.name} (${f.type})`; |
| row.appendChild(label); |
|
|
| const isSignature = f.type === 'signature' || /sign(?:ed|ature)/i.test((f.name || '') + ' ' + (f.label || '')); |
| const isDate = f.type === 'text' && /\b(date|dated)\b/i.test(`${f.name || ''} ${f.label || ''}`); |
| let input; |
| if (isSignature) { |
| const wrap = document.createElement('div'); |
| wrap.style.cssText = 'display:flex;align-items:center;gap:8px;'; |
| const btn = document.createElement('button'); |
| btn.className = 'confirm-btn confirm-btn-secondary'; |
| btn.style.cssText = 'padding:3px 10px;font-size:0.78rem;'; |
| const thumb = document.createElement('img'); |
| thumb.style.cssText = 'max-height:32px;max-width:140px;object-fit:contain;border:1px solid var(--border);border-radius:3px;background:#fff;display:none;'; |
| const clearBtn = document.createElement('button'); |
| clearBtn.textContent = '×'; |
| clearBtn.title = 'Remove signature from this field'; |
| clearBtn.className = 'confirm-btn confirm-btn-secondary'; |
| clearBtn.style.cssText = 'padding:0 8px;font-size:0.85rem;line-height:1;display:none;'; |
| const apply = (sig) => { |
| wrap.dataset.signatureId = sig.id; |
| thumb.src = sig.dataUrl; |
| thumb.style.display = ''; |
| clearBtn.style.display = ''; |
| btn.textContent = 'Change'; |
| }; |
| const clear = () => { |
| delete wrap.dataset.signatureId; |
| thumb.removeAttribute('src'); |
| thumb.style.display = 'none'; |
| clearBtn.style.display = 'none'; |
| btn.textContent = 'Sign here'; |
| }; |
| btn.textContent = 'Sign here'; |
| btn.addEventListener('click', async () => { |
| const sig = await signatureModule.pick(); |
| if (sig) apply(sig); |
| }); |
| clearBtn.addEventListener('click', clear); |
| wrap.appendChild(btn); |
| wrap.appendChild(thumb); |
| wrap.appendChild(clearBtn); |
| wrap.dataset.fieldName = f.name; |
| wrap.dataset.fieldType = 'signature'; |
| const last = signatureModule.getLastUsed && signatureModule.getLastUsed(); |
| if (last) apply(last); |
| input = wrap; |
| } else if (isDate) { |
| const wrap = document.createElement('div'); |
| wrap.style.cssText = 'display:flex;gap:6px;align-items:center;'; |
| const ti = document.createElement('input'); |
| ti.type = 'text'; |
| ti.value = f.value == null ? '' : String(f.value); |
| ti.className = 'pdf-export-input'; |
| ti.style.cssText = 'flex:1;'; |
| ti.dataset.fieldName = f.name; |
| ti.dataset.fieldType = f.type; |
| const today = document.createElement('button'); |
| today.textContent = 'Today'; |
| today.title = "Set to today's date"; |
| today.className = 'confirm-btn confirm-btn-secondary'; |
| today.style.cssText = 'padding:3px 8px;font-size:0.72rem;'; |
| today.addEventListener('click', () => { |
| const d = new Date(); |
| const dd = String(d.getDate()).padStart(2, '0'); |
| const mm = String(d.getMonth() + 1).padStart(2, '0'); |
| const yyyy = d.getFullYear(); |
| ti.value = `${dd}/${mm}/${yyyy}`; |
| }); |
| wrap.appendChild(ti); |
| wrap.appendChild(today); |
| input = wrap; |
| } else if (f.type === 'checkbox') { |
| input = document.createElement('input'); |
| input.type = 'checkbox'; |
| input.checked = !!f.value; |
| } else if (f.type === 'choice' && (f.options || []).length) { |
| input = document.createElement('select'); |
| input.className = 'pdf-export-input'; |
| const blank = document.createElement('option'); |
| blank.value = ''; |
| blank.textContent = '— (none) —'; |
| input.appendChild(blank); |
| for (const o of f.options) { |
| const opt = document.createElement('option'); |
| opt.value = o; opt.textContent = o; |
| if (o === f.value) opt.selected = true; |
| input.appendChild(opt); |
| } |
| } else { |
| input = document.createElement('input'); |
| input.type = 'text'; |
| input.value = f.value == null ? '' : String(f.value); |
| input.className = 'pdf-export-input'; |
| input.style.cssText = 'width:100%;'; |
| } |
| if (!isSignature && !isDate) { |
| input.dataset.fieldName = f.name; |
| input.dataset.fieldType = f.type; |
| } |
| row.appendChild(input); |
| sec.appendChild(row); |
| } |
| body.appendChild(sec); |
| } |
|
|
| const downloadBtn = overlay.querySelector('#pdf-export-download'); |
| downloadBtn.disabled = false; |
| downloadBtn.addEventListener('click', async () => { |
| const values = {}; |
| const signatures = {}; |
| for (const el of overlay.querySelectorAll('[data-field-name]')) { |
| const name = el.dataset.fieldName; |
| const ftype = el.dataset.fieldType; |
| if (ftype === 'signature') { |
| if (el.dataset.signatureId) signatures[name] = el.dataset.signatureId; |
| } else if (ftype === 'checkbox') { |
| values[name] = el.checked; |
| } else { |
| values[name] = el.value; |
| } |
| } |
| downloadBtn.disabled = true; |
| overlay.querySelector('#pdf-export-status').textContent = 'Building PDF…'; |
| try { |
| const r = await fetch(`${API_BASE}/api/document/${activeDocId}/export-pdf`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ values, signatures }), |
| }); |
| if (!r.ok) { |
| const t = await r.text(); |
| throw new Error(t || r.statusText); |
| } |
| const blob = await r.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| const cd = r.headers.get('Content-Disposition') || ''; |
| const m = cd.match(/filename\*?=(?:UTF-8'')?"?([^"';]+)/i); |
| const _slug = (s) => (s || 'form').replace(/\.pdf$/i, '').replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '') || 'form'; |
| a.download = (m && decodeURIComponent(m[1])) || (_slug(docs.get(activeDocId)?.title) + '_annotated.pdf'); |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| setTimeout(() => URL.revokeObjectURL(url), 1000); |
| close(); |
| } catch (e) { |
| overlay.querySelector('#pdf-export-status').textContent = 'Error: ' + e.message; |
| downloadBtn.disabled = false; |
| } |
| }); |
| } catch (e) { |
| overlay.querySelector('#pdf-export-body').innerHTML = |
| `<div style="color:#c00;">Failed to load preview: ${(e && e.message) || e}</div>`; |
| } |
| } |
|
|
| |
| |
| const _pdfViewState = new Map(); |
| const _pdfPaneFieldsByDoc = new Map(); |
| const _pdfPaneAnnotationsByDoc = new Map(); |
| const _pdfUndoStackByDoc = new Map(); |
| let _pdfPaneSaveTimer = null; |
|
|
| |
| |
| |
| |
| function _annotationRegexGlobal() { |
| return /^[ \t]*-\s+(.*?)\s*<!--\s*annotation\s+id=([\w-]+)\s+page=(\d+)\s+x=([\d.]+)\s+y=([\d.]+)\s+w=([\d.]+)\s+h=([\d.]+)(?:\s+kind=(\w+))?(?:\s+lh=([\d.]+))?\s*-->[ \t]*$/gm; |
| } |
|
|
| |
| |
| |
| function _escapeAnnotationValue(s) { |
| return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); |
| } |
| function _unescapeAnnotationValue(s) { |
| return String(s || '').replace(/\\(.)/g, (m, c) => c === 'n' ? '\n' : c === '\\' ? '\\' : m); |
| } |
|
|
| function _parseAnnotations(md) { |
| const out = []; |
| const re = _annotationRegexGlobal(); |
| let m; |
| while ((m = re.exec(md || '')) !== null) { |
| const rawVal = m[1] === '_(empty)_' ? '' : _unescapeAnnotationValue(m[1]); |
| out.push({ |
| value: rawVal, |
| id: m[2], |
| page: parseInt(m[3], 10), |
| x: parseFloat(m[4]), |
| y: parseFloat(m[5]), |
| w: parseFloat(m[6]), |
| h: parseFloat(m[7]), |
| kind: m[8] || 'text', |
| lineHeight: m[9] ? parseFloat(m[9]) : 1.3, |
| }); |
| } |
| return out; |
| } |
|
|
| function _annotationLine(a) { |
| const kind = a.kind || 'text'; |
| const lh = (a.lineHeight && Number.isFinite(a.lineHeight)) ? a.lineHeight : 1.3; |
| const escaped = a.value === '' || a.value == null ? '_(empty)_' : _escapeAnnotationValue(a.value); |
| return `- ${escaped} <!-- annotation id=${a.id} page=${a.page} x=${a.x.toFixed(2)} y=${a.y.toFixed(2)} w=${a.w.toFixed(2)} h=${a.h.toFixed(2)} kind=${kind} lh=${lh.toFixed(2)} -->`; |
| } |
|
|
| |
| |
| |
| function _writeAnnotations(md, annotations) { |
| let out = (md || '').replace(_annotationRegexGlobal(), ''); |
| out = out.replace(/\n##\s+Annotations\s*\r?\n+/g, '\n'); |
| out = out.replace(/\n{3,}/g, '\n\n'); |
| if (!annotations.length) return out; |
| if (!out.endsWith('\n')) out += '\n'; |
| out += '\n## Annotations\n\n'; |
| for (const a of annotations) out += _annotationLine(a) + '\n'; |
| return out; |
| } |
|
|
| function _newAnnotationId() { |
| return 'ann-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7); |
| } |
|
|
| function _pdfMarkdownFromLive(docId = activeDocId) { |
| const doc = docs.get(docId); |
| if (!doc) return null; |
| const annotations = _pdfPaneAnnotationsByDoc.get(docId) || []; |
| return _writeAnnotations(doc.content || '', annotations.map(a => { |
| let value = ''; |
| if (a.kind === 'check') { |
| value = '✓'; |
| } else if (a.kind === 'signature') { |
| const sid = a.el && a.el.dataset && a.el.dataset.signatureId; |
| value = sid ? `signature:${sid}` : ''; |
| } else { |
| value = (a.el && typeof a.el.value === 'string') ? a.el.value : ''; |
| } |
| return { |
| id: a.id, page: a.page, x: a.x, y: a.y, w: a.w, h: a.h, |
| kind: a.kind || 'text', |
| lineHeight: a.lineHeight || 1.3, |
| value, |
| }; |
| })); |
| } |
|
|
| function _pushPdfUndoSnapshot(docId = activeDocId) { |
| const md = _pdfMarkdownFromLive(docId); |
| if (md == null) return; |
| const stack = _pdfUndoStackByDoc.get(docId) || []; |
| if (stack[stack.length - 1] === md) return; |
| stack.push(md); |
| if (stack.length > 50) stack.shift(); |
| _pdfUndoStackByDoc.set(docId, stack); |
| } |
|
|
| async function _undoPdfPaneAction() { |
| const docId = activeDocId; |
| const stack = _pdfUndoStackByDoc.get(docId) || []; |
| const prev = stack.pop(); |
| if (!prev) return false; |
| _pdfUndoStackByDoc.set(docId, stack); |
| if (_pdfPaneSaveTimer) { |
| clearTimeout(_pdfPaneSaveTimer); |
| _pdfPaneSaveTimer = null; |
| } |
| const doc = docs.get(docId); |
| if (!doc) return false; |
| doc.content = prev; |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) ta.value = prev; |
| _setPdfSaveStatus('saving'); |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${docId}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ content: prev }), |
| }); |
| if (!res.ok) throw new Error(res.statusText || String(res.status)); |
| _setPdfSaveStatus('saved'); |
| _renderPdfPane(); |
| return true; |
| } catch (e) { |
| _setPdfSaveStatus('error', e.message || 'Undo failed'); |
| return true; |
| } |
| } |
|
|
| |
| |
| let _pdfDropMode = null; |
| |
| |
| const _pdfLastLineHeight = new Map(); |
| function _setPdfDropMode(mode) { |
| _pdfDropMode = mode; |
| const pane = document.getElementById('doc-pdf-view'); |
| if (pane) pane.style.cursor = mode ? 'crosshair' : ''; |
| |
| for (const id of ['doc-pdf-add-text-btn', 'doc-pdf-add-check-btn', 'doc-pdf-add-sign-btn']) { |
| const b = document.getElementById(id); |
| if (!b) continue; |
| const want = (mode === 'text' && id === 'doc-pdf-add-text-btn') |
| || (mode === 'check' && id === 'doc-pdf-add-check-btn') |
| || (mode === 'signature' && id === 'doc-pdf-add-sign-btn'); |
| b.style.outline = want ? '2px solid var(--accent-primary, var(--red))' : ''; |
| } |
| } |
| |
| |
| const _sigCache = new Map(); |
|
|
| |
| |
| function _encodeFieldName(name) { |
| let out = ''; |
| for (const ch of name || '') { |
| if (/[A-Za-z0-9_.\-]/.test(ch)) { |
| out += ch; |
| } else { |
| const enc = new TextEncoder().encode(ch); |
| for (const b of enc) out += '%' + b.toString(16).toUpperCase().padStart(2, '0'); |
| } |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| let _pdfPaneProximityWired = false; |
| function _wirePdfPaneProximity(pane) { |
| if (_pdfPaneProximityWired || !pane) return; |
| _pdfPaneProximityWired = true; |
| let raf = 0; |
| const buffer = 30; |
| pane.addEventListener('mousemove', (ev) => { |
| if (raf) return; |
| raf = requestAnimationFrame(() => { |
| raf = 0; |
| const refs = _pdfPaneAnnotationsByDoc.get(activeDocId) || []; |
| for (const ref of refs) { |
| if (!ref || !ref.wrap || !ref._setHandlesVisible) continue; |
| const r = ref.wrap.getBoundingClientRect(); |
| const dx = Math.max(r.left - ev.clientX, 0, ev.clientX - r.right); |
| const dy = Math.max(r.top - ev.clientY, 0, ev.clientY - r.bottom); |
| ref._setHandlesVisible(Math.hypot(dx, dy) <= buffer); |
| } |
| }); |
| }); |
| pane.addEventListener('mouseleave', () => { |
| const refs = _pdfPaneAnnotationsByDoc.get(activeDocId) || []; |
| for (const ref of refs) ref._setHandlesVisible && ref._setHandlesVisible(false); |
| }); |
| } |
|
|
| async function _pdfResponseErrorMessage(res) { |
| const text = await res.text().catch(() => ''); |
| try { |
| const data = JSON.parse(text); |
| if (typeof data?.detail === 'string') return data.detail; |
| if (data?.detail) return JSON.stringify(data.detail); |
| } catch (_) {} |
| return text || res.statusText || `HTTP ${res.status}`; |
| } |
|
|
| async function _renderPdfPane() { |
| const pane = document.getElementById('doc-pdf-view'); |
| if (!pane || !activeDocId) return; |
| _wirePdfPaneProximity(pane); |
| const docId = activeDocId; |
| |
| const savedPill = document.getElementById('doc-pdf-save-pill'); |
| pane.innerHTML = '<div style="color:#bbb;font-size:13px;text-align:center;padding:40px;">Loading PDF…</div>'; |
| if (savedPill) pane.appendChild(savedPill); |
| let data; |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${docId}/render-pages`); |
| if (!res.ok) throw new Error(await _pdfResponseErrorMessage(res)); |
| data = await res.json(); |
| } catch (e) { |
| pane.innerHTML = `<div style="color:#fbb;padding:40px;text-align:center;">Failed to load PDF view: ${_escHtml(e.message || String(e))}</div>`; |
| if (savedPill) pane.appendChild(savedPill); |
| return; |
| } |
| if (docId !== activeDocId) return; |
|
|
| pane.innerHTML = ''; |
| if (savedPill) pane.appendChild(savedPill); |
| const fieldRefs = []; |
| |
| |
| const annotationRefs = []; |
| _pdfPaneAnnotationsByDoc.set(docId, annotationRefs); |
| const liveMd = (docs.get(docId) && docs.get(docId).content) || ''; |
| const allAnnotations = _parseAnnotations(liveMd); |
| |
| |
| if (!_pdfLastLineHeight.has(docId)) { |
| for (let i = allAnnotations.length - 1; i >= 0; i--) { |
| const a = allAnnotations[i]; |
| if (a.kind === 'text' && a.lineHeight) { |
| _pdfLastLineHeight.set(docId, a.lineHeight); |
| break; |
| } |
| } |
| } |
| for (const page of data.pages) { |
| |
| |
| const pageWrap = document.createElement('div'); |
| pageWrap.style.cssText = `position:relative;margin:0 auto 16px auto;width:${page.width}px;max-width:calc(100% - 24px);aspect-ratio:${page.width} / ${page.height};background:#fff;box-shadow:0 4px 16px rgba(0,0,0,0.4);container-type:size;`; |
| const img = document.createElement('img'); |
| img.src = `${API_BASE}/api/document/${docId}/page/${page.page}.png`; |
| img.style.cssText = 'display:block;width:100%;height:100%;user-select:none;-webkit-user-drag:none;pointer-events:none;'; |
| img.draggable = false; |
| pageWrap.appendChild(img); |
|
|
| |
| |
| |
| for (const f of page.fields) { |
| const [x0, y0, x1, y1] = f.rect_px; |
| const wPct = ((x1 - x0) / page.width) * 100; |
| const hPct = ((y1 - y0) / page.height) * 100; |
| const lPct = (x0 / page.width) * 100; |
| const tPct = (y0 / page.height) * 100; |
| const isSig = f.type === 'signature' || /sign(?:ed|ature)/i.test((f.name || '') + ' ' + (f.label || '')); |
| let el; |
| const baseStyle = `position:absolute;left:${lPct}%;top:${tPct}%;width:${wPct}%;height:${hPct}%;box-sizing:border-box;font-family:inherit;`; |
| if (isSig) { |
| |
| |
| |
| el = document.createElement('div'); |
| el.style.cssText = baseStyle + 'cursor:pointer;display:flex;align-items:center;justify-content:center;overflow:hidden;'; |
| el.dataset.fieldName = f.name; |
| el.dataset.fieldType = 'signature'; |
|
|
| |
| const initialSigId = (typeof f.value === 'string' && f.value.startsWith('signature:')) |
| ? f.value.slice('signature:'.length).trim() : ''; |
| const renderSigUI = async (sigId) => { |
| el.innerHTML = ''; |
| if (sigId) { |
| el.dataset.signatureId = sigId; |
| const img = document.createElement('img'); |
| img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; |
| |
| try { |
| if (!_sigCache.has(sigId)) { |
| const r = await fetch(`${API_BASE}/api/signatures`); |
| const data = await r.json(); |
| for (const s of data.signatures || []) _sigCache.set(s.id, s.data_url); |
| } |
| const dataUrl = _sigCache.get(sigId); |
| if (dataUrl) img.src = dataUrl; |
| else throw new Error('not found'); |
| el.appendChild(img); |
| el.style.border = '1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent)'; |
| el.style.background = 'transparent'; |
| } catch { |
| el.removeAttribute('data-signature-id'); |
| renderSigUI(''); |
| } |
| } else { |
| delete el.dataset.signatureId; |
| el.style.border = '1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent)'; |
| el.style.background = 'color-mix(in srgb, var(--accent, var(--red)) 10%, transparent)'; |
| const span = document.createElement('span'); |
| span.style.cssText = 'color:var(--accent, var(--red));font-size:11px;'; |
| span.textContent = 'Sign here'; |
| el.appendChild(span); |
| } |
| }; |
| el.addEventListener('click', async (ev) => { |
| ev.stopPropagation(); |
| const sig = await signatureModule.pick(); |
| if (sig) { |
| _sigCache.set(sig.id, sig.dataUrl); |
| await renderSigUI(sig.id); |
| _schedulePdfPaneSave(); |
| } |
| }); |
| renderSigUI(initialSigId); |
| } else if (f.type === 'checkbox') { |
| el = document.createElement('input'); |
| el.type = 'checkbox'; |
| el.checked = !!f.value; |
| el.style.cssText = baseStyle + 'cursor:pointer;'; |
| } else if (f.type === 'choice' && (f.options || []).length) { |
| el = document.createElement('select'); |
| const blank = document.createElement('option'); |
| blank.value = ''; blank.textContent = '—'; |
| el.appendChild(blank); |
| for (const opt of f.options) { |
| const o = document.createElement('option'); |
| o.value = opt; o.textContent = opt; |
| if (opt === f.value) o.selected = true; |
| el.appendChild(o); |
| } |
| el.style.cssText = baseStyle + 'border:1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);background:rgba(255,255,255,0.85);font-size:11px;padding:0 2px;'; |
| } else { |
| el = document.createElement('input'); |
| el.type = 'text'; |
| el.value = f.value == null ? '' : String(f.value); |
| |
| |
| |
| const fontPx = Math.max(8, Math.min(14, Math.round((y1 - y0) * 0.4))); |
| el.style.cssText = baseStyle + `border:1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);background:rgba(255,255,255,0.85);font-size:${fontPx}px;padding:0 2px;`; |
| } |
| if (!isSig) { |
| el.dataset.fieldName = f.name; |
| el.dataset.fieldType = f.type; |
| el.addEventListener('input', _schedulePdfPaneSave); |
| el.addEventListener('change', _schedulePdfPaneSave); |
| } |
| pageWrap.appendChild(el); |
| |
| |
| fieldRefs.push({ name: f.name, type: isSig ? 'signature' : f.type, el }); |
|
|
| |
| |
| const isDate = f.type === 'text' && /\b(date|dated)\b/i.test(`${f.name} ${f.label}`); |
| if (isDate) { |
| const today = document.createElement('button'); |
| today.type = 'button'; |
| today.textContent = 'Today'; |
| today.title = "Set to today's date"; |
| today.style.cssText = `position:absolute;left:calc(${lPct}% + ${wPct}%);top:${tPct}%;height:${hPct}%;margin-left:4px;padding:0 6px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);background:rgba(255,255,255,0.95);color:var(--accent, var(--red));border-radius:3px;cursor:pointer;font-size:10px;line-height:1;white-space:nowrap;`; |
| today.addEventListener('click', () => { |
| const d = new Date(); |
| const dd = String(d.getDate()).padStart(2, '0'); |
| const mm = String(d.getMonth() + 1).padStart(2, '0'); |
| const yyyy = d.getFullYear(); |
| el.value = `${dd}/${mm}/${yyyy}`; |
| _schedulePdfPaneSave(); |
| }); |
| pageWrap.appendChild(today); |
| } |
| } |
| |
| for (const ann of allAnnotations) { |
| if (ann.page !== page.page) continue; |
| const built = _buildAnnotation(pageWrap, ann); |
| annotationRefs.push(built.ref); |
| } |
| |
| |
| |
| |
| pageWrap.addEventListener('click', (ev) => { |
| if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return; |
| if (!_pdfDropMode) return; |
| const rect = pageWrap.getBoundingClientRect(); |
| const xPct = ((ev.clientX - rect.left) / rect.width) * 100; |
| const yPct = ((ev.clientY - rect.top) / rect.height) * 100; |
| |
| |
| |
| const sizes = { |
| text: { w: 8, h: 2.5 }, |
| check: { w: 2.5, h: 2.5 }, |
| signature: { w: 22, h: 6 }, |
| }; |
| const size = sizes[_pdfDropMode] || sizes.text; |
| |
| |
| |
| const centered = _pdfDropMode === 'check'; |
| const x = Math.max(0, Math.min(100 - size.w, centered ? xPct - size.w / 2 : xPct)); |
| const y = Math.max(0, Math.min(100 - size.h, centered ? yPct - size.h / 2 : yPct)); |
| const ann = { |
| id: _newAnnotationId(), |
| page: page.page, |
| x, y, w: size.w, h: size.h, |
| value: _pdfDropMode === 'check' ? '[ ]' : '', |
| kind: _pdfDropMode, |
| |
| |
| lineHeight: _pdfDropMode === 'text' ? (_pdfLastLineHeight.get(docId) || 1.3) : undefined, |
| }; |
| _pushPdfUndoSnapshot(docId); |
| const built = _buildAnnotation(pageWrap, ann); |
| annotationRefs.push(built.ref); |
| if (_pdfDropMode === 'text') { |
| built.ref.el.focus(); |
| } else if (_pdfDropMode === 'signature') { |
| |
| |
| built.ref.el.click(); |
| } |
| _schedulePdfPaneSave(); |
| |
| |
| }); |
|
|
| pane.appendChild(pageWrap); |
| } |
| _pdfPaneFieldsByDoc.set(docId, fieldRefs); |
| } |
|
|
| |
| |
| |
| function _buildAnnotation(pageWrap, ann) { |
| const kind = ann.kind || 'text'; |
| const wrap = document.createElement('div'); |
| wrap.className = 'pdf-annotation-wrap'; |
| wrap.style.cssText = `position:absolute;left:${ann.x}%;top:${ann.y}%;width:${ann.w}%;height:${ann.h}%;box-sizing:border-box;z-index:2;`; |
| wrap.dataset.annId = ann.id; |
| wrap.dataset.annKind = kind; |
|
|
| let input; |
| if (kind === 'check') { |
| |
| |
| input = document.createElement('div'); |
| input.style.cssText = `width:100%;height:100%;display:flex;align-items:center;justify-content:center;user-select:none;pointer-events:none;`; |
| input.innerHTML = `<svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block;"><path d="M4 12 L10 18 L20 6" fill="none" stroke="#111" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>`; |
| } else if (kind === 'signature') { |
| input = document.createElement('div'); |
| input.style.cssText = `width:100%;height:100%;box-sizing:border-box;border:1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;font-size:10px;color:var(--accent, var(--red));`; |
| input.textContent = (ann.value && ann.value.startsWith('signature:')) ? '' : 'Sign here'; |
| input.dataset.signatureId = (ann.value && ann.value.startsWith('signature:')) ? ann.value.slice(10) : ''; |
| } else { |
| |
| |
| |
| |
| |
| |
| input = document.createElement('textarea'); |
| input.value = ann.value || ''; |
| input.placeholder = 'Type…'; |
| input.rows = 1; |
| input.spellcheck = false; |
| const lh = ann.lineHeight || 1.3; |
| input.style.cssText = `width:100%;height:100%;box-sizing:border-box;border:1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);font-family:inherit;font-size:1.5cqh;line-height:${lh};padding:1px 4px;color:#111;resize:none;overflow:auto;white-space:pre-wrap;`; |
| } |
|
|
| |
| |
| |
| const _isTouch = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches; |
| const HS = _isTouch ? 28 : 20; |
| |
| |
| const OFF = -HS; |
| const HIDE = _isTouch ? '' : 'none'; |
|
|
| |
| const del = document.createElement('button'); |
| del.type = 'button'; |
| del.textContent = '✖'; |
| del.title = 'Delete annotation'; |
| del.style.cssText = `position:absolute;top:${OFF}px;right:${OFF}px;width:${HS}px;height:${HS}px;padding:0 0 0 1px;border:1px solid var(--accent, var(--red));background:#fff;color:var(--accent, var(--red));border-radius:50%;cursor:pointer;font-size:11px;line-height:1;display:${HIDE};font-weight:bold;touch-action:none;`; |
|
|
| |
| const grip = document.createElement('div'); |
| grip.title = 'Drag to move'; |
| grip.textContent = '☰'; |
| grip.style.cssText = `position:absolute;top:${OFF}px;left:${OFF}px;width:${HS}px;height:${HS}px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:3px;cursor:move;font-size:11px;line-height:${HS - 2}px;text-align:center;display:${HIDE};touch-action:none;`; |
|
|
| |
| const resize = document.createElement('div'); |
| resize.title = 'Drag to resize'; |
| resize.style.cssText = `position:absolute;bottom:${OFF}px;right:${OFF}px;width:${HS}px;height:${HS}px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:3px;cursor:nwse-resize;display:${HIDE};touch-action:none;`; |
| resize.innerHTML = '<svg width="14" height="14" viewBox="0 0 10 10" style="display:block;margin:auto;height:100%;"><path d="M2 8 L8 2 M5 8 L8 5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round"/></svg>'; |
|
|
| let menuBtn = null; |
| if (kind === 'text') { |
| menuBtn = document.createElement('button'); |
| menuBtn.type = 'button'; |
| menuBtn.textContent = '…'; |
| menuBtn.title = 'Text annotation options'; |
| menuBtn.style.cssText = `position:absolute;bottom:${OFF}px;left:${OFF}px;width:${HS}px;height:${HS}px;padding:0;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:50%;cursor:pointer;font-size:15px;line-height:0.8;display:${HIDE};font-weight:bold;touch-action:none;`; |
| } |
|
|
| |
| |
| const _setHandlesVisible = (show) => { |
| const dismissed = wrap.dataset.controlsDismissed === '1'; |
| const v = (show && !dismissed) ? '' : 'none'; |
| del.style.display = v; |
| grip.style.display = v; |
| resize.style.display = v; |
| if (menuBtn) menuBtn.style.display = v; |
| }; |
| if (!_isTouch) { |
| wrap.addEventListener('mouseenter', () => _setHandlesVisible(true)); |
| wrap.addEventListener('mouseleave', () => _setHandlesVisible(false)); |
| } |
| wrap.addEventListener('pointerdown', (ev) => { |
| if (ev.target === del || ev.target === grip || ev.target === resize || ev.target === menuBtn) return; |
| wrap.dataset.controlsDismissed = '0'; |
| _setHandlesVisible(true); |
| }); |
|
|
| const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible }; |
|
|
| if (kind === 'check') { |
| |
| ref.value = '✓'; |
| } else if (kind === 'signature') { |
| const _renderSig = async (sigId) => { |
| input.innerHTML = ''; |
| if (!sigId) { |
| input.dataset.signatureId = ''; |
| input.style.background = 'color-mix(in srgb, var(--accent, var(--red)) 10%, transparent)'; |
| input.style.border = '1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent)'; |
| const span = document.createElement('span'); |
| span.textContent = 'Sign here'; |
| input.appendChild(span); |
| return; |
| } |
| input.dataset.signatureId = sigId; |
| try { |
| if (!_sigCache.has(sigId)) { |
| const r = await fetch(`${API_BASE}/api/signatures`); |
| const data = await r.json(); |
| for (const s of data.signatures || []) _sigCache.set(s.id, s.data_url); |
| } |
| const dataUrl = _sigCache.get(sigId); |
| if (!dataUrl) throw new Error('not found'); |
| const img = document.createElement('img'); |
| img.src = dataUrl; |
| img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; |
| input.appendChild(img); |
| input.style.background = 'transparent'; |
| input.style.border = '1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent)'; |
| } catch { |
| _renderSig(''); |
| } |
| }; |
| input.addEventListener('click', async (ev) => { |
| ev.stopPropagation(); |
| const sig = await signatureModule.pick(); |
| if (sig) { |
| _pushPdfUndoSnapshot(); |
| _sigCache.set(sig.id, sig.dataUrl); |
| await _renderSig(sig.id); |
| ref.value = `signature:${sig.id}`; |
| _schedulePdfPaneSave(); |
| } |
| }); |
| |
| _renderSig(input.dataset.signatureId); |
| } else { |
| |
| |
| |
| let _mirror = null; |
| const _autoGrow = () => { |
| const pageRect = pageWrap.getBoundingClientRect(); |
| if (!pageRect.height || !pageRect.width) return; |
|
|
| |
| |
| if (!_mirror) { |
| _mirror = document.createElement('div'); |
| _mirror.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font-family:inherit;padding:1px 4px;left:-9999px;top:-9999px;'; |
| document.body.appendChild(_mirror); |
| } |
| const cs = window.getComputedStyle(input); |
| _mirror.style.fontSize = cs.fontSize; |
| _mirror.style.fontWeight = cs.fontWeight; |
| _mirror.style.fontFamily = cs.fontFamily; |
| _mirror.style.letterSpacing = cs.letterSpacing; |
| let widestPx = 0; |
| const lines = (input.value || input.placeholder || '').split('\n'); |
| for (const line of lines) { |
| _mirror.textContent = line || ' '; |
| if (_mirror.offsetWidth > widestPx) widestPx = _mirror.offsetWidth; |
| } |
| const neededWPct = ((widestPx + 12) / pageRect.width) * 100; |
| if (neededWPct > ref.w) { |
| ref.w = Math.min(100 - ref.x, neededWPct); |
| wrap.style.width = ref.w + '%'; |
| } |
|
|
| |
| const prev = input.style.height; |
| input.style.height = 'auto'; |
| const neededHpx = input.scrollHeight + 4; |
| input.style.height = prev || '100%'; |
| const neededHpct = (neededHpx / pageRect.height) * 100; |
| if (neededHpct > ref.h) { |
| ref.h = Math.min(100 - ref.y, neededHpct); |
| wrap.style.height = ref.h + '%'; |
| } |
| }; |
| input.addEventListener('input', () => { |
| if (wrap.dataset.textUndoCaptured !== '1') { |
| _pushPdfUndoSnapshot(); |
| wrap.dataset.textUndoCaptured = '1'; |
| } |
| ref.value = input.value; |
| _autoGrow(); |
| _schedulePdfPaneSave(); |
| }); |
| input.addEventListener('change', () => { |
| ref.value = input.value; |
| _autoGrow(); |
| _schedulePdfPaneSave(); |
| }); |
| input.addEventListener('focus', () => { |
| _pushPdfUndoSnapshot(); |
| wrap.dataset.textUndoCaptured = '1'; |
| }); |
| input.addEventListener('keydown', (ev) => { |
| if (ev.key === 'Escape') input.blur(); |
| }); |
| |
| |
| requestAnimationFrame(_autoGrow); |
| |
| |
| ref._autoGrow = _autoGrow; |
| } |
|
|
| del.addEventListener('click', (ev) => { |
| ev.stopPropagation(); |
| _pushPdfUndoSnapshot(); |
| _removeAnnotation(ref); |
| }); |
| |
| |
| grip.addEventListener('pointerdown', (ev) => { |
| ev.preventDefault(); |
| ev.stopPropagation(); |
| _pushPdfUndoSnapshot(); |
| try { grip.setPointerCapture(ev.pointerId); } catch (_) {} |
| |
| |
| del.style.display = 'none'; |
| resize.style.display = 'none'; |
| if (menuBtn) menuBtn.style.display = 'none'; |
| const start = { mx: ev.clientX, my: ev.clientY, x: ref.x, y: ref.y }; |
| const rect = pageWrap.getBoundingClientRect(); |
| const onMove = (e) => { |
| const dxPct = ((e.clientX - start.mx) / rect.width) * 100; |
| const dyPct = ((e.clientY - start.my) / rect.height) * 100; |
| ref.x = Math.max(0, Math.min(100 - ref.w, start.x + dxPct)); |
| ref.y = Math.max(0, Math.min(100 - ref.h, start.y + dyPct)); |
| wrap.style.left = ref.x + '%'; |
| wrap.style.top = ref.y + '%'; |
| }; |
| const onUp = () => { |
| document.removeEventListener('pointermove', onMove); |
| document.removeEventListener('pointerup', onUp); |
| _setHandlesVisible(true); |
| _schedulePdfPaneSave(); |
| }; |
| document.addEventListener('pointermove', onMove); |
| document.addEventListener('pointerup', onUp); |
| }); |
|
|
| |
| resize.addEventListener('pointerdown', (ev) => { |
| ev.preventDefault(); |
| ev.stopPropagation(); |
| _pushPdfUndoSnapshot(); |
| try { resize.setPointerCapture(ev.pointerId); } catch (_) {} |
| |
| del.style.display = 'none'; |
| grip.style.display = 'none'; |
| if (menuBtn) menuBtn.style.display = 'none'; |
| const start = { mx: ev.clientX, my: ev.clientY, w: ref.w, h: ref.h }; |
| const rect = pageWrap.getBoundingClientRect(); |
| const onMove = (e) => { |
| const dwPct = ((e.clientX - start.mx) / rect.width) * 100; |
| const dhPct = ((e.clientY - start.my) / rect.height) * 100; |
| ref.w = Math.max(1, Math.min(100 - ref.x, start.w + dwPct)); |
| ref.h = Math.max(0.8, Math.min(100 - ref.y, start.h + dhPct)); |
| wrap.style.width = ref.w + '%'; |
| wrap.style.height = ref.h + '%'; |
| }; |
| const onUp = () => { |
| document.removeEventListener('pointermove', onMove); |
| document.removeEventListener('pointerup', onUp); |
| _setHandlesVisible(true); |
| _schedulePdfPaneSave(); |
| }; |
| document.addEventListener('pointermove', onMove); |
| document.addEventListener('pointerup', onUp); |
| }); |
|
|
| |
| |
| if (kind === 'text') { |
| const popover = document.createElement('div'); |
| popover.className = 'pdf-annotation-text-menu'; |
| popover.style.cssText = `position:absolute;bottom:${OFF + HS + 4}px;left:${OFF}px;display:none;background:#fff;border:1px solid var(--accent, var(--red));border-radius:4px;padding:6px 8px;box-shadow:0 2px 8px rgba(0,0,0,0.2);z-index:10;flex-direction:column;align-items:stretch;gap:6px;font-size:10px;color:#222;white-space:nowrap;`; |
| popover.innerHTML = ` |
| <div style="display:flex;align-items:center;gap:6px;"> |
| <span>Line spacing</span> |
| <input type="range" min="1" max="3" step="0.05" value="${ann.lineHeight || 1.3}" style="width:90px;accent-color:var(--accent, var(--red));" /> |
| <input type="number" class="lh-val" min="0.5" max="5" step="0.01" value="${(ann.lineHeight || 1.3).toFixed(2)}" style="width:54px;font-size:10px;padding:1px 7px 1px 3px;border:1px solid var(--accent, var(--red));border-radius:3px;text-align:right;accent-color:var(--accent, var(--red));" /> |
| </div> |
| <button type="button" class="pdf-ann-today" style="height:22px;padding:0 7px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);color:var(--accent, var(--red));border-radius:4px;cursor:pointer;font-size:10px;font-family:inherit;text-align:left;">Today</button> |
| `; |
| const slider = popover.querySelector('input[type="range"]'); |
| const valInput = popover.querySelector('.lh-val'); |
| const todayBtn = popover.querySelector('.pdf-ann-today'); |
| const _applyLh = (v, fromSlider) => { |
| if (!Number.isFinite(v)) return; |
| if (popover.dataset.lhUndoCaptured !== '1') { |
| _pushPdfUndoSnapshot(); |
| popover.dataset.lhUndoCaptured = '1'; |
| } |
| v = Math.max(0.5, Math.min(5, v)); |
| |
| |
| |
| const allRefs = _pdfPaneAnnotationsByDoc.get(activeDocId) || []; |
| for (const r of allRefs) { |
| if (r.kind !== 'text') continue; |
| r.lineHeight = v; |
| if (r.el && r.el.style) r.el.style.lineHeight = String(v); |
| |
| |
| if (typeof r._autoGrow === 'function') r._autoGrow(); |
| } |
| ref.lineHeight = v; |
| input.style.lineHeight = String(v); |
| if (fromSlider) valInput.value = v.toFixed(2); |
| else slider.value = String(Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), v))); |
| _pdfLastLineHeight.set(activeDocId, v); |
| _schedulePdfPaneSave(); |
| }; |
| slider.addEventListener('input', () => _applyLh(parseFloat(slider.value), true)); |
| valInput.addEventListener('input', () => _applyLh(parseFloat(valInput.value), false)); |
| |
| valInput.addEventListener('blur', () => { |
| const v = parseFloat(valInput.value); |
| if (!Number.isFinite(v)) valInput.value = (ref.lineHeight || 1.3).toFixed(2); |
| popover.dataset.lhUndoCaptured = '0'; |
| }); |
| todayBtn.addEventListener('click', () => { |
| _pushPdfUndoSnapshot(); |
| const d = new Date(); |
| const dd = String(d.getDate()).padStart(2, '0'); |
| const mm = String(d.getMonth() + 1).padStart(2, '0'); |
| const yyyy = d.getFullYear(); |
| const text = `${dd}/${mm}/${yyyy}`; |
| const start = input.selectionStart ?? input.value.length; |
| const end = input.selectionEnd ?? start; |
| input.value = input.value.slice(0, start) + text + input.value.slice(end); |
| const next = start + text.length; |
| try { input.setSelectionRange(next, next); } catch (_) {} |
| ref.value = input.value; |
| if (typeof ref._autoGrow === 'function') ref._autoGrow(); |
| _schedulePdfPaneSave(); |
| input.focus({ preventScroll: true }); |
| }); |
| |
| popover.addEventListener('mousedown', (e) => e.stopPropagation()); |
| popover.addEventListener('click', (e) => e.stopPropagation()); |
| menuBtn?.addEventListener('click', (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| popover.style.display = popover.style.display === 'flex' ? 'none' : 'flex'; |
| }); |
| wrap.appendChild(popover); |
| ref.lineHeight = ann.lineHeight || 1.3; |
| } |
|
|
| wrap.appendChild(input); |
| wrap.appendChild(del); |
| wrap.appendChild(grip); |
| wrap.appendChild(resize); |
| if (menuBtn) wrap.appendChild(menuBtn); |
| pageWrap.appendChild(wrap); |
| return { wrap, ref }; |
| } |
|
|
| function _removeAnnotation(ref) { |
| if (!ref || !ref.wrap) return; |
| const docId = activeDocId; |
| const refs = _pdfPaneAnnotationsByDoc.get(docId) || []; |
| const idx = refs.indexOf(ref); |
| if (idx >= 0) refs.splice(idx, 1); |
| ref.wrap.remove(); |
| _schedulePdfPaneSave(); |
| } |
|
|
| |
| |
| |
| |
| async function _aiFillAnnotations() { |
| const docId = activeDocId; |
| if (!docId) return; |
| const doc = docs.get(docId); |
| if (!doc) return; |
|
|
| const instruction = window.prompt( |
| 'What should the AI fill in?\n(e.g. "My name is Jane Doe, address 123 Main St, dob 1990-01-15")' |
| ); |
| if (!instruction || !instruction.trim()) return; |
|
|
| _setPdfSaveStatus('saving'); |
| const btn = document.getElementById('doc-pdf-ai-fill-btn'); |
| if (btn) { btn.disabled = true; btn.textContent = 'Thinking…'; } |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${docId}/ai-fill-annotations`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ instruction: instruction.trim() }), |
| }); |
| if (!res.ok) { |
| const t = await res.text().catch(() => res.statusText); |
| throw new Error(t || res.statusText); |
| } |
| const data = await res.json(); |
| const proposed = (data && data.annotations) || []; |
| if (!proposed.length) { |
| _setPdfSaveStatus('idle'); |
| if (uiModule && uiModule.showToast) uiModule.showToast('AI found nothing to fill'); |
| return; |
| } |
| |
| |
| const existing = _parseAnnotations(doc.content || ''); |
| const combined = existing.slice(); |
| for (const a of proposed) { |
| combined.push({ |
| id: _newAnnotationId(), |
| page: parseInt(a.page, 10) || 1, |
| x: Math.max(0, Math.min(100, parseFloat(a.x) || 0)), |
| y: Math.max(0, Math.min(100, parseFloat(a.y) || 0)), |
| w: Math.max(0.5, Math.min(100, parseFloat(a.w) || 22)), |
| h: Math.max(0.3, Math.min(100, parseFloat(a.h) || 3.5)), |
| value: String(a.value || ''), |
| }); |
| } |
| const newMd = _writeAnnotations(doc.content || '', combined); |
| doc.content = newMd; |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) ta.value = newMd; |
| const r2 = await fetch(`${API_BASE}/api/document/${docId}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ content: newMd }), |
| }); |
| if (!r2.ok) { |
| const t = await r2.text().catch(() => r2.statusText); |
| throw new Error(t || r2.statusText); |
| } |
| _setPdfSaveStatus('saved'); |
| if (uiModule && uiModule.showToast) uiModule.showToast(`AI added ${proposed.length} annotations`); |
| _renderPdfPane(); |
| } catch (e) { |
| console.error('AI fill failed:', e); |
| _setPdfSaveStatus('error', `AI fill failed: ${e.message || e}`); |
| } finally { |
| if (btn) { btn.disabled = false; btn.textContent = 'AI fill'; } |
| } |
| } |
|
|
| function _schedulePdfPaneSave() { |
| _setPdfSaveStatus('dirty'); |
| if (_pdfPaneSaveTimer) clearTimeout(_pdfPaneSaveTimer); |
| _pdfPaneSaveTimer = setTimeout(() => _savePdfPaneToMarkdown(), 600); |
| } |
|
|
| function _setPdfSaveStatus(status, msg) { |
| const pill = document.getElementById('doc-pdf-save-pill'); |
| if (!pill) return; |
| const palette = { |
| idle: { txt: '', bg: 'transparent', fg: 'transparent' }, |
| dirty: { txt: 'Editing…', bg: 'var(--panel)', fg: 'var(--fg)' }, |
| saving: { txt: 'Saving…', bg: 'var(--panel)', fg: 'var(--fg)' }, |
| saved: { txt: 'Saved', bg: 'rgba(34,197,94,0.85)', fg: '#fff' }, |
| error: { txt: msg || 'Save failed', bg: 'var(--red)', fg: 'var(--bg)' }, |
| }; |
| const p = palette[status] || palette.idle; |
| pill.textContent = p.txt; |
| pill.style.background = p.bg; |
| pill.style.color = p.fg; |
| pill.style.display = p.txt ? '' : 'none'; |
| if (status === 'saved') { |
| setTimeout(() => { |
| if (pill.textContent === 'Saved') _setPdfSaveStatus('idle'); |
| }, 1200); |
| } |
| } |
|
|
| async function _savePdfPaneToMarkdown(opts = {}) { |
| _pdfPaneSaveTimer = null; |
| const docId = activeDocId; |
| const fields = _pdfPaneFieldsByDoc.get(docId) || []; |
| const annotations = _pdfPaneAnnotationsByDoc.get(docId) || []; |
| if (!docId || (!fields.length && !annotations.length)) return false; |
| const doc = docs.get(docId); |
| if (!doc) return false; |
|
|
| let md = doc.content || ''; |
| let changed = 0; |
| for (const ref of fields) { |
| |
| |
| |
| const encName = _encodeFieldName(ref.name); |
| const re = new RegExp( |
| `^(\\s*-\\s+)(.*?)(\\s*<!--\\s*field=${encName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\s+type=\\w+\\s*-->\\s*)$`, |
| 'm' |
| ); |
| const m = md.match(re); |
| if (!m) continue; |
| const body = m[2]; |
| let newBody = body; |
| if (ref.type === 'checkbox') { |
| const mark = ref.el.checked ? '[x]' : '[ ]'; |
| newBody = body.replace(/^\s*\[[ xX]\]/, mark); |
| } else if (ref.type === 'choice') { |
| const v = ref.el.value || '_(not selected)_'; |
| newBody = body.replace(/(\][\s]*:[ ]*).*$/, `$1${v}`); |
| } else if (ref.type === 'signature') { |
| const sid = ref.el.dataset.signatureId || ''; |
| const v = sid ? `signature:${sid}` : '_(unsigned)_'; |
| newBody = body.replace(/(:\*\*[ ]*).*$/, `$1${v}`); |
| } else { |
| const v = ref.el.value === '' ? '_(empty)_' : ref.el.value; |
| newBody = body.replace(/(:\*\*[ ]*).*$/, `$1${v}`); |
| } |
| if (newBody !== body) { |
| md = md.replace(re, `${m[1]}${newBody}${m[3]}`); |
| changed++; |
| } |
| } |
| |
| |
| md = _writeAnnotations(md, annotations.map(a => { |
| let value = ''; |
| if (a.kind === 'check') { |
| value = '✓'; |
| } else if (a.kind === 'signature') { |
| const sid = a.el && a.el.dataset && a.el.dataset.signatureId; |
| value = sid ? `signature:${sid}` : ''; |
| } else { |
| value = (a.el && typeof a.el.value === 'string') ? a.el.value : ''; |
| } |
| return { |
| id: a.id, page: a.page, x: a.x, y: a.y, w: a.w, h: a.h, |
| kind: a.kind || 'text', |
| lineHeight: a.lineHeight || 1.3, |
| value, |
| }; |
| })); |
| if (md === doc.content) { |
| _setPdfSaveStatus('idle'); |
| return true; |
| } |
| doc.content = md; |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) ta.value = md; |
| _setPdfSaveStatus('saving'); |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${docId}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ content: md }), |
| keepalive: !!opts.keepalive, |
| }); |
| if (!res.ok) { |
| const t = await res.text().catch(() => res.statusText); |
| _setPdfSaveStatus('error', `Save failed: ${res.status}`); |
| console.warn('PDF-pane save HTTP error:', res.status, t); |
| return false; |
| } |
| _setPdfSaveStatus('saved'); |
| return true; |
| } catch (e) { |
| _setPdfSaveStatus('error', e.message || 'Save failed'); |
| console.warn('PDF-pane save failed:', e); |
| return false; |
| } |
| } |
|
|
| |
| window.addEventListener('beforeunload', () => { |
| if (_pdfPaneSaveTimer) { |
| clearTimeout(_pdfPaneSaveTimer); |
| _savePdfPaneToMarkdown({ keepalive: true }); |
| } |
| }); |
|
|
| async function _refreshPdfPreviewIframe() { |
| |
| |
| const pane = document.getElementById('doc-pdf-view'); |
| if (!pane || !activeDocId) return; |
| if (pane.style.display === 'none') return; |
| if (_pdfPaneSaveTimer) { |
| clearTimeout(_pdfPaneSaveTimer); |
| await _savePdfPaneToMarkdown(); |
| } |
| _renderPdfPane(); |
| } |
|
|
| async function _setPdfViewActive(active) { |
| const pane = document.getElementById('doc-pdf-view'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| const btn = document.getElementById('doc-pdf-view-btn'); |
| if (!pane || !wrap) return; |
| if (active) { |
| _pdfViewState.set(activeDocId, true); |
| wrap.style.display = 'none'; |
| pane.style.display = ''; |
| _renderPdfPane(); |
| btn?.classList.add('active'); |
| } else { |
| |
| if (_pdfPaneSaveTimer) { |
| clearTimeout(_pdfPaneSaveTimer); |
| await _savePdfPaneToMarkdown(); |
| } |
| _pdfViewState.set(activeDocId, false); |
| pane.style.display = 'none'; |
| |
| const savedPill = document.getElementById('doc-pdf-save-pill'); |
| pane.innerHTML = ''; |
| if (savedPill) pane.appendChild(savedPill); |
| _pdfPaneFieldsByDoc.delete(activeDocId); |
| _pdfPaneAnnotationsByDoc.delete(activeDocId); |
| wrap.style.display = ''; |
| btn?.classList.remove('active'); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function _syncHeaderBarVisibility() { |
| const hdr = document.getElementById('doc-editor-actions'); |
| if (!hdr) return; |
| |
| |
| if (docs.get(activeDocId)?.language === 'email') { hdr.style.display = 'none'; return; } |
| const vis = (id) => { |
| const e = document.getElementById(id); |
| if (!e || !e.parentElement) return false; |
| |
| |
| |
| if (!hdr.contains(e)) return false; |
| return e.style.display !== 'none'; |
| }; |
| |
| |
| |
| const visible = vis('doc-stream-indicator') |
| || vis('doc-version-badge') |
| || vis('doc-export-pdf-btn') |
| || vis('doc-pdf-view-btn'); |
| hdr.style.display = visible ? '' : 'none'; |
| } |
|
|
| function _syncHeaderActions() { |
| const actionBtn = document.getElementById('doc-header-preview-btn'); |
| const exportBtn = document.getElementById('doc-export-pdf-btn'); |
| const pdfViewBtn = document.getElementById('doc-pdf-view-btn'); |
| const pdfPane = document.getElementById('doc-pdf-view'); |
| const langSelect = document.getElementById('doc-language-select'); |
| const live = document.getElementById('doc-editor-textarea')?.value |
| || docs.get(activeDocId)?.content |
| || ''; |
| const isForm = _isFormBackedDoc(live); |
| |
| |
| |
| |
| const _copyBtn = document.getElementById('doc-footer-copy-btn'); |
| if (_copyBtn) { |
| const _ad = docs.get(activeDocId); |
| const _replyable = !!(_ad && _ad.sourceEmailUid && _ad.sourceEmailFolder); |
| if (_replyable && _copyBtn.dataset.mode !== 'reply') { |
| _copyBtn.dataset.mode = 'reply'; |
| _copyBtn.title = 'Reply to the sender with this filled file attached'; |
| _copyBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>Attach'; |
| } else if (!_replyable && _copyBtn.dataset.mode !== 'copy') { |
| _copyBtn.dataset.mode = 'copy'; |
| _copyBtn.title = 'Copy document'; |
| _copyBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy'; |
| } |
| } |
| |
| |
| |
| if (exportBtn) exportBtn.style.display = 'none'; |
| if (pdfViewBtn) pdfViewBtn.style.display = 'none'; |
| if (true) { |
| const explicit = _pdfViewState.get(activeDocId); |
| const active = isForm && explicit !== false; |
| |
| if (isForm && langSelect) { |
| const want = active ? 'pdf' : 'markdown'; |
| if (langSelect.value !== want) langSelect.value = want; |
| } |
| if (pdfPane) { |
| if (active) { |
| if (pdfPane.style.display === 'none') { |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap) wrap.style.display = 'none'; |
| pdfPane.style.display = ''; |
| _renderPdfPane(); |
| } |
| } else if (pdfPane.style.display !== 'none') { |
| pdfPane.style.display = 'none'; |
| pdfPane.innerHTML = ''; |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap) wrap.style.display = ''; |
| } |
| } |
| } |
| if (!actionBtn) return; |
|
|
| const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); |
| const canPreview = ['markdown', 'csv'].includes(lang) || _isRenderLang(lang); |
| const canRun = ['javascript', 'js', 'python', 'py', 'bash', 'sh', 'shell', 'zsh'].includes(lang); |
|
|
| const _eyeIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; |
| const _penIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>'; |
| const _playIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>'; |
| const _codeIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>'; |
|
|
| |
| const _mdPreview = document.getElementById('doc-md-preview'); |
| const _csvPreview = document.getElementById('doc-csv-preview'); |
| const _htmlPreview = document.getElementById('doc-html-preview'); |
| const _outputPanel = document.getElementById('doc-run-output'); |
| const _mdActive = _mdPreview && _mdPreview.style.display !== 'none'; |
| const _csvActive = _csvPreview && _csvPreview.style.display !== 'none'; |
| const _htmlActive = _htmlPreview && _htmlPreview.style.display !== 'none'; |
| const _outputActive = _outputPanel && _outputPanel.style.display !== 'none'; |
|
|
| let show = false; |
| actionBtn.classList.remove('active'); |
|
|
| |
| |
| const mdToggle = document.getElementById('doc-md-view-toggle'); |
| if (mdToggle) mdToggle.style.display = (lang === 'markdown') ? 'inline-flex' : 'none'; |
| const renderToggle = document.getElementById('doc-render-view-toggle'); |
| if (renderToggle) { |
| renderToggle.style.display = _hasViewToggle(lang) ? 'inline-flex' : 'none'; |
| |
| |
| |
| |
| const runBtn = renderToggle.querySelector('[data-renderview="run"]'); |
| if (runBtn) { |
| let icon, title; |
| if (lang === 'csv') { |
| icon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>'; |
| title = 'Table view'; |
| } else if (_isRenderLang(lang)) { |
| icon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; |
| title = 'Preview'; |
| } else { |
| icon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>'; |
| title = 'Run'; |
| } |
| if (runBtn.dataset.lastIcon !== lang) { |
| runBtn.innerHTML = icon; |
| runBtn.title = title; |
| runBtn.dataset.lastIcon = lang; |
| } |
| } |
| |
| |
| |
| const codeBtn = renderToggle.querySelector('[data-renderview="code"]'); |
| if (codeBtn) { |
| const codeIco = (lang === 'csv') |
| ? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>' |
| : '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>'; |
| const codeTitle = (lang === 'csv') ? 'Edit' : 'Edit code'; |
| if (codeBtn.dataset.lastIcon !== lang) { |
| codeBtn.innerHTML = codeIco; |
| codeBtn.title = codeTitle; |
| codeBtn.dataset.lastIcon = lang; |
| } |
| } |
| |
| |
| |
| |
| |
| let _viewActive = false; |
| if (lang === 'csv') _viewActive = _csvActive; |
| else if (_isRenderLang(lang)) _viewActive = _htmlActive; |
| else _viewActive = _outputActive; |
| const _codeBtn2 = renderToggle.querySelector('[data-renderview="code"]'); |
| const _runBtn2 = renderToggle.querySelector('[data-renderview="run"]'); |
| _codeBtn2?.classList.toggle('active', !_viewActive); |
| _runBtn2?.classList.toggle('active', _viewActive); |
| } |
|
|
| if (lang === 'markdown') { |
| show = false; |
| if (mdToggle) { |
| mdToggle.querySelector('[data-mdview="edit"]')?.classList.toggle('active', !_mdActive); |
| mdToggle.querySelector('[data-mdview="preview"]')?.classList.toggle('active', _mdActive); |
| } |
| } else if (lang === 'csv') { |
| show = true; |
| actionBtn.innerHTML = _csvActive ? _penIco : '<span style="font-size:12px;font-weight:600;">⊞</span>'; |
| actionBtn.title = _csvActive ? 'Edit' : 'Table View'; |
| if (_csvActive) actionBtn.classList.add('active'); |
| } else if (_isRenderLang(lang)) { |
| |
| |
| show = false; |
| if (renderToggle) { |
| renderToggle.querySelector('[data-renderview="code"]')?.classList.toggle('active', !_htmlActive); |
| renderToggle.querySelector('[data-renderview="run"]')?.classList.toggle('active', _htmlActive); |
| } |
| } else if (canRun) { |
| show = true; |
| actionBtn.innerHTML = _outputActive ? _codeIco : _playIco; |
| actionBtn.title = _outputActive ? 'Hide output' : 'Run'; |
| if (_outputActive) actionBtn.classList.add('active'); |
| } |
|
|
| |
| |
| |
| if (_hasViewToggle(lang)) show = false; |
| actionBtn.style.display = show ? '' : 'none'; |
|
|
| |
| |
| _syncHeaderBarVisibility(); |
| } |
|
|
| |
|
|
| function _parseEmailHeader(content) { |
| const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: content || '' }; |
| if (!content) return empty; |
| const parts = content.split(/\n---\n/); |
| if (parts.length < 2) return empty; |
| const header = parts[0]; |
| const body = parts.slice(1).join('\n---\n'); |
| const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: body }; |
| for (const line of header.split('\n')) { |
| const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):\s*(.*)$/i); |
| if (m) { |
| let key = m[1].toLowerCase(); |
| if (key === 'in-reply-to') key = 'inReplyTo'; |
| else if (key === 'x-source-uid') key = 'sourceUid'; |
| else if (key === 'x-source-folder') key = 'sourceFolder'; |
| else if (key === 'x-attachments') { |
| fields.attachments = m[2].trim().split('|').map(a => { |
| const [index, filename, size] = a.split(':'); |
| return { index: parseInt(index), filename, size: parseInt(size) }; |
| }); |
| continue; |
| } |
| fields[key] = m[2].trim(); |
| } |
| } |
| return fields; |
| } |
|
|
| function _buildEmailContent(to, subject, inReplyTo, references, body, sourceUid, sourceFolder, cc, bcc) { |
| let header = `To: ${to}`; |
| if (cc) header += `\nCc: ${cc}`; |
| if (bcc) header += `\nBcc: ${bcc}`; |
| header += `\nSubject: ${subject}`; |
| if (inReplyTo) header += `\nIn-Reply-To: ${inReplyTo}`; |
| if (references) header += `\nReferences: ${references}`; |
| if (sourceUid) header += `\nX-Source-UID: ${sourceUid}`; |
| if (sourceFolder) header += `\nX-Source-Folder: ${sourceFolder}`; |
| return header + '\n---\n' + body; |
| } |
|
|
| |
| function _emailBodyToHtml(text) { |
| const t = (text || '').trim(); |
| if (!t) return ''; |
| |
| |
| |
| if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t; |
| try { return markdownModule.mdToHtml(text); } |
| catch (_) { |
| const d = document.createElement('div'); d.textContent = text; |
| return d.innerHTML.replace(/\n/g, '<br>'); |
| } |
| } |
| |
| |
| |
| function _syncEmailRichbody(rich) { |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (!ta) return; |
| ta.value = rich.innerText; |
| ta.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| function _wireEmailRichbody(rich) { |
| if (rich._wired) { _syncEmailRichbody(rich); return; } |
| rich._wired = true; |
| rich.addEventListener('input', () => _syncEmailRichbody(rich)); |
| |
| |
| |
| |
| const syncActive = () => { |
| if (!rich.isConnected || rich.style.display === 'none') return; |
| |
| |
| if (!rich.contains(document.activeElement) && document.activeElement !== rich) return; |
| const tb = document.getElementById('doc-md-toolbar'); |
| if (!tb) return; |
| const set = (sel, on) => { const b = tb.querySelector(sel); if (b) b.classList.toggle('is-active', !!on); }; |
| try { |
| set('[data-md="bold"]', document.queryCommandState('bold')); |
| set('[data-md="italic"]', document.queryCommandState('italic')); |
| set('[data-md="strike"]', document.queryCommandState('strikeThrough')); |
| } catch (_) {} |
| |
| |
| const cur = _currentBlockTag(rich); |
| const hBtn = tb.querySelector('[data-dd="heading"]'); |
| if (hBtn) hBtn.classList.toggle('is-active', cur === 'h1' || cur === 'h2' || cur === 'h3'); |
| try { |
| const inList = document.queryCommandState('insertOrderedList') || document.queryCommandState('insertUnorderedList'); |
| const lBtn = tb.querySelector('[data-dd="list"]'); |
| if (lBtn) lBtn.classList.toggle('is-active', !!inList); |
| } catch (_) {} |
| }; |
| rich.addEventListener('keyup', syncActive); |
| rich.addEventListener('mouseup', syncActive); |
| rich.addEventListener('focus', syncActive); |
| rich.addEventListener('input', syncActive); |
| |
| document.addEventListener('selectionchange', () => { |
| const sel = window.getSelection(); |
| if (sel && sel.rangeCount && rich.contains(sel.anchorNode)) syncActive(); |
| }); |
| rich._syncActive = syncActive; |
| } |
| function _emailRichbodyActive() { |
| const r = document.getElementById('doc-email-richbody'); |
| return r && r.style.display !== 'none' ? r : null; |
| } |
|
|
| function _captureEmailBodyFocusState() { |
| const rich = _emailRichbodyActive(); |
| const ta = document.getElementById('doc-editor-textarea'); |
| const active = document.activeElement; |
| if (rich && (active === rich || rich.contains(active))) { |
| const sel = window.getSelection(); |
| const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null; |
| return { |
| type: 'rich', |
| range: range && rich.contains(range.commonAncestorContainer) ? range.cloneRange() : null, |
| }; |
| } |
| if (ta && active === ta) { |
| return { |
| type: 'textarea', |
| start: ta.selectionStart, |
| end: ta.selectionEnd, |
| }; |
| } |
| return null; |
| } |
|
|
| function _restoreEmailBodyFocusState(state) { |
| if (!state) return; |
| requestAnimationFrame(() => { |
| if (state.type === 'rich') { |
| const rich = _emailRichbodyActive(); |
| if (!rich) return; |
| rich.focus({ preventScroll: true }); |
| if (state.range) { |
| const sel = window.getSelection(); |
| if (sel) { |
| sel.removeAllRanges(); |
| sel.addRange(state.range); |
| } |
| } |
| } else if (state.type === 'textarea') { |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (!ta) return; |
| ta.focus({ preventScroll: true }); |
| if (Number.isFinite(state.start) && Number.isFinite(state.end)) { |
| try { ta.setSelectionRange(state.start, state.end); } catch (_) {} |
| } |
| } |
| }); |
| } |
|
|
| function _stripEmailReplyQuoteText(text) { |
| const original = String(text || ''); |
| if (!original) return { body: '', stripped: false }; |
| const lines = original.split('\n'); |
| const quoteIdx = lines.findIndex(line => |
| /^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim()) |
| || /^On .+ wrote:\s*$/i.test(line.trim()) |
| ); |
| if (quoteIdx <= 0) return { body: original.trim(), stripped: false }; |
| const body = lines.slice(0, quoteIdx).join('\n').trim(); |
| return { body, stripped: !!body }; |
| } |
|
|
| function _emailReplyOwnText(text) { |
| return _stripEmailReplyQuoteText(text).body; |
| } |
|
|
| function _setEmailBodyText(textarea, value) { |
| if (!textarea) return; |
| textarea.value = value || ''; |
| syncHighlighting(); |
| const rich = _emailRichbodyActive(); |
| if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value); |
| } |
|
|
| async function _streamEmailBodyText(textarea, value) { |
| if (!textarea) return; |
| const finalText = String(value || ''); |
| const maxFrames = 90; |
| const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames)); |
| textarea.value = ''; |
| const rich = _emailRichbodyActive(); |
| if (rich) rich.innerHTML = ''; |
| for (let i = 0; i < finalText.length; i += chunk) { |
| const next = finalText.slice(0, i + chunk); |
| textarea.value = next; |
| if (rich) rich.innerHTML = _emailBodyToHtml(next); |
| await new Promise(resolve => requestAnimationFrame(resolve)); |
| } |
| _setEmailBodyText(textarea, finalText); |
| } |
|
|
| function _focusEmailBodyEnd() { |
| const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea'); |
| if (!target) return; |
| target.focus(); |
| if (target.isContentEditable) { |
| const range = document.createRange(); |
| range.selectNodeContents(target); |
| range.collapse(false); |
| const sel = window.getSelection(); |
| if (sel) { |
| sel.removeAllRanges(); |
| sel.addRange(range); |
| } |
| } else if (typeof target.setSelectionRange === 'function') { |
| const len = target.value.length; |
| target.setSelectionRange(len, len); |
| } |
| } |
|
|
| function _syncEmailHeaderSummary() { |
| const to = document.getElementById('doc-email-to')?.value?.trim() || 'No recipient'; |
| const subject = document.getElementById('doc-email-subject')?.value?.trim() || 'No subject'; |
| const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; |
| const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; |
| const summary = document.getElementById('doc-email-collapse-summary'); |
| if (!summary) return; |
| const extras = []; |
| if (cc) extras.push('Cc'); |
| if (bcc) extras.push('Bcc'); |
| summary.textContent = `${to} · ${subject}${extras.length ? ` · ${extras.join('/')}` : ''}`; |
| summary.title = summary.textContent; |
| } |
|
|
| function _setEmailHeaderCollapsed(collapsed, { manual = true } = {}) { |
| const header = document.getElementById('doc-email-header'); |
| const btn = document.getElementById('doc-email-collapse-btn'); |
| if (!header) return; |
| if (window.innerWidth > 768) collapsed = false; |
| header.classList.toggle('doc-email-header-collapsed', !!collapsed); |
| if (btn) { |
| btn.setAttribute('aria-expanded', String(!collapsed)); |
| btn.title = collapsed ? 'Show email fields' : 'Hide email fields'; |
| } |
| const doc = activeDocId && docs.get(activeDocId); |
| if (doc && manual) doc._emailHeaderCollapsed = !!collapsed; |
| if (manual && !collapsed) _emailHeaderManualExpandUntil = Date.now() + 1400; |
| _syncEmailHeaderSummary(); |
| } |
|
|
| function _shouldAutoCollapseEmailHeader() { |
| return window.innerWidth <= 768; |
| } |
|
|
| function _maybeAutoCollapseEmailHeader() { |
| const doc = activeDocId && docs.get(activeDocId); |
| if (!doc || doc.language !== 'email') return; |
| if (Date.now() < _emailHeaderManualExpandUntil) return; |
| if (document.activeElement?.closest?.('#doc-email-fields')) return; |
| if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false }); |
| } |
|
|
| function _showEmailFields(doc) { |
| const emailHeader = document.getElementById('doc-email-header'); |
| const emailActions = document.getElementById('doc-email-actions'); |
| |
| const mdToolbar = document.getElementById('doc-md-toolbar'); |
| if (mdToolbar) { |
| mdToolbar.style.display = ''; |
| if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); |
| } |
| |
| document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; }); |
| if (emailHeader) emailHeader.style.display = ''; |
| if (emailActions) emailActions.style.display = ''; |
| |
| |
| |
| |
| const docActions = document.getElementById('doc-editor-actions'); |
| if (docActions) docActions.style.display = 'none'; |
| const docFooter = document.getElementById('doc-actions-footer'); |
| if (docFooter) docFooter.style.display = 'none'; |
| if (emailActions) { |
| const _lang = document.getElementById('doc-language-select'); |
| const _sendSplit = emailActions.querySelector('.email-send-split'); |
| if (_lang && _sendSplit) emailActions.insertBefore(_lang, _sendSplit); |
| } |
| |
| document.getElementById('doc-editor-textarea')?.classList.add('email-mode'); |
| document.getElementById('doc-editor-code')?.classList.add('email-mode'); |
| document.getElementById('doc-editor-highlight')?.classList.add('email-mode'); |
| const fields = _parseEmailHeader(doc.content || ''); |
| const toInput = document.getElementById('doc-email-to'); |
| const subjectInput = document.getElementById('doc-email-subject'); |
| const inReplyTo = document.getElementById('doc-email-in-reply-to'); |
| const refs = document.getElementById('doc-email-references'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (toInput) toInput.value = fields.to; |
| if (subjectInput) subjectInput.value = fields.subject; |
| _setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false }); |
| if (subjectInput && !subjectInput._emailTabBodyBound) { |
| subjectInput._emailTabBodyBound = true; |
| subjectInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Tab' && !e.shiftKey) { |
| e.preventDefault(); |
| _focusEmailBodyEnd(); |
| } |
| }); |
| } |
| if (inReplyTo) inReplyTo.value = fields.inReplyTo; |
| if (refs) refs.value = fields.references; |
| const sourceUid = document.getElementById('doc-email-source-uid'); |
| const sourceFolder = document.getElementById('doc-email-source-folder'); |
| if (sourceUid) sourceUid.value = fields.sourceUid || ''; |
| if (sourceFolder) sourceFolder.value = fields.sourceFolder || ''; |
| |
| const unreadBtn = document.getElementById('doc-email-unread-btn'); |
| if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none'; |
| |
| const attDiv = document.getElementById('doc-email-attachments'); |
| if (attDiv) { |
| attDiv.innerHTML = ''; |
| if (fields.attachments && fields.attachments.length > 0 && fields.sourceUid) { |
| attDiv.style.display = ''; |
| for (const att of fields.attachments) { |
| const isPdf = (att.filename || '').toLowerCase().endsWith('.pdf'); |
| const sizeKb = att.size > 0 ? `${Math.round(att.size / 1024)} KB` : ''; |
| const chipHtml = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_escHtml(att.filename)}</span><span class="att-size">${sizeKb}</span>`; |
| |
| const _withSpinner = async (chip, fn) => { |
| if (chip.dataset.loading === '1') return; |
| chip.dataset.loading = '1'; |
| const orig = chip.innerHTML; |
| chip.innerHTML = ''; |
| const sp = spinnerModule.createWhirlpool(14); |
| sp.style.marginRight = '6px'; |
| chip.appendChild(sp); |
| const lbl = document.createElement('span'); |
| lbl.textContent = att.filename; |
| chip.appendChild(lbl); |
| try { await fn(); } |
| finally { chip.dataset.loading = ''; chip.innerHTML = orig; } |
| }; |
| if (isPdf) { |
| |
| const chip = document.createElement('button'); |
| chip.type = 'button'; |
| chip.className = 'email-attachment-chip email-attachment-chip-pdf'; |
| |
| chip.title = att.filename; |
| chip.innerHTML = chipHtml; |
| chip.addEventListener('click', () => _withSpinner(chip, async () => { |
| try { |
| const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX'); |
| const res = await fetch(`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`, { method: 'POST' }); |
| const data = await res.json(); |
| if (data.doc_id) { |
| await loadDocument(data.doc_id); |
| } else if (uiModule) { |
| uiModule.showError(data.error || 'Failed to open PDF'); |
| window.open(`${API_BASE}/api/email/attachment/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`, '_blank'); |
| } |
| } catch (e) { |
| console.error('Open PDF attachment failed:', e); |
| if (uiModule) uiModule.showError('Failed to open PDF'); |
| } |
| })); |
| attDiv.appendChild(chip); |
| } else { |
| |
| |
| |
| const chip = document.createElement('button'); |
| chip.type = 'button'; |
| chip.className = 'email-attachment-chip'; |
| |
| chip.title = `Download ${att.filename}`; |
| chip.innerHTML = chipHtml; |
| chip.addEventListener('click', () => _withSpinner(chip, async () => { |
| try { |
| const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX'); |
| const res = await fetch(`${API_BASE}/api/email/attachment/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const blob = await res.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; a.download = att.filename; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| setTimeout(() => URL.revokeObjectURL(url), 1000); |
| } catch (e) { |
| console.error('Download attachment failed:', e); |
| if (uiModule) uiModule.showError('Download failed: ' + e.message); |
| } |
| })); |
| attDiv.appendChild(chip); |
| } |
| } |
| } else { |
| attDiv.style.display = 'none'; |
| } |
| } |
| if (textarea) { |
| textarea.value = fields.body; |
| |
| if (doc) doc._originalBody = fields.body; |
| syncHighlighting(); |
| } |
| |
| |
| |
| const _rich = document.getElementById('doc-email-richbody'); |
| const _srcWrap = document.getElementById('doc-editor-wrap'); |
| if (_rich && _srcWrap) { |
| _srcWrap.style.display = 'none'; |
| _rich.style.display = ''; |
| _rich.innerHTML = _emailBodyToHtml(fields.body); |
| _wireEmailRichbody(_rich); |
| setTimeout(() => { |
| try { |
| const _isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0; |
| if (!_isTouch) _rich.focus(); |
| _rich.scrollTop = 0; |
| } catch (_) {} |
| }, 50); |
| } |
| |
| _renderComposeAttachments(); |
| |
| const ccRow = document.getElementById('doc-email-cc-row'); |
| const bccRow = document.getElementById('doc-email-bcc-row'); |
| const ccToggle = document.getElementById('doc-email-show-cc'); |
| const ccInput = document.getElementById('doc-email-cc'); |
| const bccInput = document.getElementById('doc-email-bcc'); |
| if (ccInput) ccInput.value = fields.cc || ''; |
| if (bccInput) bccInput.value = fields.bcc || ''; |
| const hasCcBcc = !!(fields.cc || fields.bcc); |
| if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none'; |
| if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none'; |
| if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : ''; |
| _syncEmailHeaderSummary(); |
| } |
|
|
| async function _uploadComposeFiles(files) { |
| const list = Array.from(files || []); |
| if (list.length === 0) return; |
| const doc = docs.get(activeDocId); |
| if (!doc) return; |
| if (doc.language !== 'email') return; |
| if (!doc._composeAtts) doc._composeAtts = []; |
|
|
| for (const file of list) { |
| try { |
| const fd = new FormData(); |
| fd.append('file', file); |
| const res = await fetch(`${API_BASE}/api/email/compose-upload`, { |
| method: 'POST', |
| body: fd, |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| doc._composeAtts.push({ |
| token: data.token, |
| filename: data.filename, |
| size: data.size, |
| }); |
| } else { |
| if (uiModule) uiModule.showError(`Failed to upload ${file.name}: ${data.error || ''}`); |
| } |
| } catch (err) { |
| if (uiModule) uiModule.showError(`Failed to upload ${file.name}`); |
| } |
| } |
| _renderComposeAttachments(); |
| } |
|
|
| async function _handleAttachUpload(e) { |
| const files = e.target.files; |
| e.target.value = ''; |
| await _uploadComposeFiles(files); |
| } |
|
|
| function _renderComposeAttachments() { |
| const container = document.getElementById('doc-email-compose-atts'); |
| if (!container) return; |
| const doc = docs.get(activeDocId); |
| const atts = doc?._composeAtts || []; |
| if (atts.length === 0) { |
| container.style.display = 'none'; |
| container.innerHTML = ''; |
| return; |
| } |
| container.style.display = ''; |
| container.innerHTML = ''; |
| for (const att of atts) { |
| const chip = document.createElement('span'); |
| chip.className = 'email-compose-chip'; |
| const sizeKb = att.size > 0 ? `${Math.round(att.size / 1024)} KB` : ''; |
| chip.innerHTML = ` |
| <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> |
| <span class="compose-chip-name">${_escHtml(att.filename)}</span> |
| <span class="att-size">${sizeKb}</span> |
| <button class="compose-chip-remove" title="Remove">×</button> |
| `; |
| chip.querySelector('.compose-chip-remove').addEventListener('click', async (e) => { |
| e.stopPropagation(); |
| try { |
| await fetch(`${API_BASE}/api/email/compose-upload/${encodeURIComponent(att.token)}`, { method: 'DELETE' }); |
| } catch (_) {} |
| const d = docs.get(activeDocId); |
| if (d) d._composeAtts = d._composeAtts.filter(a => a.token !== att.token); |
| _renderComposeAttachments(); |
| }); |
| container.appendChild(chip); |
| } |
| } |
|
|
| |
| |
| |
| |
| function _splitRecipientsAndFragment(rawValue) { |
| const cut = (rawValue || '').lastIndexOf(','); |
| if (cut < 0) return { confirmed: '', fragment: (rawValue || '').trimStart() }; |
| return { |
| confirmed: rawValue.slice(0, cut + 1).trimStart(), |
| fragment: rawValue.slice(cut + 1).trimStart(), |
| }; |
| } |
|
|
| |
| |
| |
| function _commitRecipient(input, sugg, email) { |
| if (!input) return; |
| const { confirmed } = _splitRecipientsAndFragment(input.value); |
| |
| const head = confirmed ? confirmed.replace(/\s+$/, '') + ' ' : ''; |
| input.value = head + email + ', '; |
| if (sugg) sugg.style.display = 'none'; |
| input.focus(); |
| |
| const end = input.value.length; |
| try { input.setSelectionRange(end, end); } catch (_) {} |
| } |
|
|
| |
| |
| |
| |
| async function _searchContacts(input, sugg) { |
| if (!input || !sugg) return; |
| const { fragment } = _splitRecipientsAndFragment(input.value); |
| if (!fragment || fragment.length < 1) { sugg.style.display = 'none'; return; } |
| try { |
| const res = await fetch(`${API_BASE}/api/contacts/search?q=${encodeURIComponent(fragment)}`); |
| const data = await res.json(); |
| if (!data.results || data.results.length === 0) { |
| sugg.style.display = 'none'; |
| return; |
| } |
| |
| |
| const already = new Set( |
| (input.value || '').split(',').map(s => { |
| const m = s.match(/<([^>]+)>/); |
| return (m ? m[1] : s).trim().toLowerCase(); |
| }).filter(Boolean) |
| ); |
| sugg.innerHTML = ''; |
| let count = 0; |
| for (const c of data.results) { |
| for (const em of (c.emails || [])) { |
| if (already.has(em.toLowerCase())) continue; |
| const item = document.createElement('div'); |
| item.className = 'contact-suggestion'; |
| item.innerHTML = `<span class="contact-name">${_escHtml(c.name)}</span><span class="contact-email">${_escHtml(em)}</span>`; |
| |
| item.addEventListener('mousedown', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); }); |
| item.addEventListener('click', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); }); |
| sugg.appendChild(item); |
| count += 1; |
| } |
| } |
| if (count === 0) { sugg.style.display = 'none'; return; } |
| |
| const first = sugg.querySelector('.contact-suggestion'); |
| if (first) first.classList.add('active'); |
| sugg.style.display = ''; |
| } catch (e) { |
| sugg.style.display = 'none'; |
| } |
| } |
|
|
| |
| |
| function _wireRecipientAutocomplete(inputId, suggId) { |
| const input = document.getElementById(inputId); |
| const sugg = document.getElementById(suggId); |
| if (!input || !sugg) return; |
| let timer = null; |
| input.addEventListener('input', () => { |
| if (timer) clearTimeout(timer); |
| timer = setTimeout(() => _searchContacts(input, sugg), 150); |
| }); |
| input.addEventListener('blur', () => { |
| setTimeout(() => { sugg.style.display = 'none'; }, 200); |
| }); |
| input.addEventListener('keydown', (e) => { |
| const open = sugg.style.display !== 'none'; |
| const items = open ? sugg.querySelectorAll('.contact-suggestion') : []; |
| const active = open ? sugg.querySelector('.contact-suggestion.active') : null; |
| let idx = active ? Array.from(items).indexOf(active) : -1; |
| if (open && e.key === 'ArrowDown') { |
| e.preventDefault(); |
| idx = Math.min(items.length - 1, idx + 1); |
| items.forEach(it => it.classList.remove('active')); |
| if (items[idx]) items[idx].classList.add('active'); |
| } else if (open && e.key === 'ArrowUp') { |
| e.preventDefault(); |
| idx = Math.max(0, idx - 1); |
| items.forEach(it => it.classList.remove('active')); |
| if (items[idx]) items[idx].classList.add('active'); |
| } else if (e.key === 'Enter') { |
| |
| |
| |
| |
| if (active) { |
| e.preventDefault(); |
| const em = active.querySelector('.contact-email')?.textContent?.trim(); |
| if (em) _commitRecipient(input, sugg, em); |
| } else { |
| const { fragment } = _splitRecipientsAndFragment(input.value); |
| if (/^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/.test(fragment.trim())) { |
| e.preventDefault(); |
| _commitRecipient(input, sugg, fragment.trim()); |
| } |
| } |
| } else if (e.key === 'Tab' && active) { |
| e.preventDefault(); |
| const em = active.querySelector('.contact-email')?.textContent?.trim(); |
| if (em) _commitRecipient(input, sugg, em); |
| } else if (e.key === 'Escape') { |
| sugg.style.display = 'none'; |
| } else if (e.key === ',' || (e.key === ' ' && input.value.trim().endsWith(','))) { |
| |
| if (active) { |
| e.preventDefault(); |
| const em = active.querySelector('.contact-email')?.textContent?.trim(); |
| if (em) _commitRecipient(input, sugg, em); |
| } |
| } |
| }); |
| } |
|
|
| function _hideEmailFields() { |
| const emailHeader = document.getElementById('doc-email-header'); |
| const emailActions = document.getElementById('doc-email-actions'); |
| if (emailHeader) emailHeader.style.display = 'none'; |
| if (emailActions) emailActions.style.display = 'none'; |
| |
| document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; }); |
| |
| |
| const docActions = document.getElementById('doc-editor-actions'); |
| if (docActions) docActions.style.display = ''; |
| const docFooter = document.getElementById('doc-actions-footer'); |
| if (docFooter) docFooter.style.display = ''; |
| |
| |
| if (docFooter) { |
| const _lang = document.getElementById('doc-language-select'); |
| const _split = docFooter.querySelector('#doc-copy-export-split'); |
| if (_lang && _split) docFooter.insertBefore(_lang, _split); |
| } |
| |
| const _rich = document.getElementById('doc-email-richbody'); |
| if (_rich) _rich.style.display = 'none'; |
| const _srcWrap = document.getElementById('doc-editor-wrap'); |
| if (_srcWrap) _srcWrap.style.display = ''; |
| |
| document.getElementById('doc-editor-textarea')?.classList.remove('email-mode'); |
| document.getElementById('doc-editor-code')?.classList.remove('email-mode'); |
| document.getElementById('doc-editor-highlight')?.classList.remove('email-mode'); |
| } |
|
|
| const _ATTACH_RE = /\b(attach(ed|ment|ments|ing)?|enclosed|enclosing|PFA|find attached|see attached|ci-joint|en pi[eè]ce jointe|ajout[eé]|joint|jointe|anbei|im Anhang|beigef[uü]gt|添付|fichier joint)\b/i; |
|
|
| function _bodyMentionsAttachment(text) { |
| if (!text) return false; |
| |
| const parts = text.split(/^>|^On .* wrote:/m); |
| const own = parts[0] || ''; |
| return _ATTACH_RE.test(own); |
| } |
|
|
| function _confirmMissingAttachment() { |
| return new Promise(resolve => { |
| const overlay = document.createElement('div'); |
| overlay.className = 'modal'; |
| overlay.style.display = 'flex'; |
| overlay.innerHTML = ` |
| <div class="modal-content" style="width:360px;max-width:90vw;"> |
| <div class="modal-header"><h4>No attachments found</h4></div> |
| <div class="modal-body" style="padding:16px;font-size:13px;opacity:0.8;"> |
| Your message mentions an attachment, but nothing is attached. Send anyway? |
| </div> |
| <div class="modal-footer" style="display:flex;gap:8px;justify-content:flex-end;"> |
| <button class="memory-toolbar-btn" id="att-warn-cancel">Go back</button> |
| <button class="memory-toolbar-btn" id="att-warn-send" style="background:var(--accent-primary,var(--red));color:#fff;border-color:var(--accent-primary,var(--red));">Send anyway</button> |
| </div> |
| </div> |
| `; |
| document.body.appendChild(overlay); |
| const cleanup = (val) => { overlay.remove(); resolve(val); }; |
| overlay.querySelector('#att-warn-cancel').addEventListener('click', () => cleanup(false)); |
| overlay.querySelector('#att-warn-send').addEventListener('click', () => cleanup(true)); |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); |
| }); |
| } |
|
|
| async function _sendEmail() { |
| const sendDocId = activeDocId; |
| const to = document.getElementById('doc-email-to')?.value?.trim(); |
| const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; |
| const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; |
| const subject = document.getElementById('doc-email-subject')?.value?.trim(); |
| const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); |
| const references = document.getElementById('doc-email-references')?.value?.trim(); |
| const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim(); |
| const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; |
| |
| |
| const _rich = _emailRichbodyActive(); |
| if (_rich) _syncEmailRichbody(_rich); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim(); |
| const bodyHtml = _rich ? _rich.innerHTML : null; |
| const doc = docs.get(activeDocId); |
| const attachments = (doc?._composeAtts || []).map(a => a.token); |
| if (!to || !body) { |
| if (uiModule) uiModule.showError('To and body are required'); |
| return; |
| } |
| if (inReplyTo && !_emailReplyOwnText(body)) { |
| if (uiModule) uiModule.showError('Reply body is empty'); |
| return; |
| } |
| |
| if (attachments.length === 0 && _bodyMentionsAttachment(body)) { |
| const proceed = await _confirmMissingAttachment(); |
| if (!proceed) return; |
| } |
| const btn = document.getElementById('doc-email-send-btn'); |
| const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); |
| let sendSpinner = null; |
| let origBtnHtml = ''; |
| let detachedEmailDoc = null; |
| if (btn) { |
| btn.disabled = true; |
| origBtnHtml = btn.innerHTML; |
| sendSpinner = spinnerModule.createWhirlpool(14); |
| sendSpinner.element.style.cssText = 'display:inline-block;vertical-align:-2px;margin-right:6px;width:14px;height:14px;'; |
| btn.innerHTML = ''; |
| btn.appendChild(sendSpinner.element); |
| btn.appendChild(document.createTextNode('Sending')); |
| } |
| try { |
| let canceled = false; |
| if (uiModule) { |
| uiModule.showToast('Sending', { |
| duration: 3200, |
| leadingIcon: 'spinner', |
| action: 'Cancel', |
| onAction: () => { canceled = true; }, |
| }); |
| } |
| await _sleep(3000); |
| if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId); |
| await _sleep(200); |
| if (canceled) { |
| _restoreDetachedEmailDoc(detachedEmailDoc); |
| detachedEmailDoc = null; |
| if (uiModule) uiModule.showToast('Send canceled'); |
| return; |
| } |
|
|
| const activeAccountId = await _resolveComposeSendAccountId(); |
| const res = await fetch(`${API_BASE}/api/email/send`, { |
| method: 'POST', |
| credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml, |
| in_reply_to: inReplyTo || null, references: references || null, |
| attachments: attachments.length > 0 ? attachments : null, |
| account_id: activeAccountId, |
| wait_for_delivery: true, |
| }), |
| }); |
| let data = null; |
| try { |
| data = await res.json(); |
| } catch (_) { |
| data = { success: false, error: `Send failed (${res.status})` }; |
| } |
| if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`; |
| if (data.success) { |
| if (uiModule) { |
| uiModule.showToast('Message sent', { |
| duration: 7000, |
| leadingIcon: 'check', |
| action: 'View Message', |
| onAction: () => { |
| import('./emailLibrary.js').then(mod => { |
| const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); |
| if (open) open({ |
| account_id: data.account_id || activeAccountId || null, |
| folder: data.sent_folder || 'Sent', |
| uid: data.sent_uid || null, |
| }); |
| }).catch(() => {}); |
| }, |
| }); |
| } |
| |
| |
| const _contactPieces = [to, cc, bcc].join(',').split(/[,;]/).map(s => s.trim()).filter(Boolean); |
| const _seenContacts = new Set(); |
| for (const piece of _contactPieces) { |
| const match = piece.match(/^(.*?)<([^>]+)>$/); |
| const email = (match ? match[2] : piece).trim(); |
| const name = (match ? match[1] : '').replace(/^["']|["']$/g, '').trim(); |
| if (!email || !/@/.test(email)) continue; |
| const key = email.toLowerCase(); |
| if (_seenContacts.has(key)) continue; |
| _seenContacts.add(key); |
| fetch(`${API_BASE}/api/contacts/add`, { |
| method: 'POST', |
| credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ name, email }), |
| }).catch(() => {}); |
| } |
| |
| if (sourceUid) { |
| fetch(`${API_BASE}/api/email/mark-answered/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }).catch(() => {}); |
| |
| window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid } })); |
| } |
| |
| |
| |
| if (sendDocId) { |
| fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' }).catch(() => {}); |
| const wasActiveSentDoc = activeDocId === sendDocId; |
| docs.delete(sendDocId); |
| if (wasActiveSentDoc) { |
| activeDocId = null; |
| const nextId = _visibleDocIdsForCurrentSession().find(id => docs.has(id)); |
| if (nextId) switchToDoc(nextId); |
| else closePanel(); |
| } else { |
| renderTabs(); |
| } |
| _syncDocIndicator(); |
| } |
| } else { |
| _restoreDetachedEmailDoc(detachedEmailDoc); |
| detachedEmailDoc = null; |
| if (uiModule) uiModule.showError(data.error || 'Failed to send'); |
| } |
| } catch (e) { |
| _restoreDetachedEmailDoc(detachedEmailDoc); |
| detachedEmailDoc = null; |
| if (uiModule) uiModule.showError(e?.message ? `Failed to send email: ${e.message}` : 'Failed to send email'); |
| } finally { |
| if (sendSpinner) sendSpinner.destroy(); |
| if (btn) { |
| btn.disabled = false; |
| if (origBtnHtml) btn.innerHTML = origBtnHtml; |
| } |
| } |
| } |
|
|
| async function _saveDraft() { |
| const to = document.getElementById('doc-email-to')?.value?.trim(); |
| const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; |
| const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; |
| const subject = document.getElementById('doc-email-subject')?.value?.trim(); |
| const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); |
| const references = document.getElementById('doc-email-references')?.value?.trim(); |
| const _rich = _emailRichbodyActive(); |
| if (_rich) _syncEmailRichbody(_rich); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim(); |
| const bodyHtml = _rich ? _rich.innerHTML : null; |
| const btn = document.getElementById('doc-email-draft-btn'); |
| if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; } |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 18000); |
| try { |
| const res = await fetch(`${API_BASE}/api/email/draft`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| signal: controller.signal, |
| body: JSON.stringify({ |
| to: to || '', |
| cc: cc || null, |
| bcc: bcc || null, |
| subject: subject || '', |
| body: body || '', |
| body_html: bodyHtml, |
| in_reply_to: inReplyTo || null, |
| references: references || null, |
| account_id: window.__odysseusActiveEmailAccount || null, |
| }), |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| if (uiModule) uiModule.showToast('Draft saved to mailbox'); |
| } else { |
| if (uiModule) uiModule.showError(data.error || 'Failed to save draft'); |
| } |
| } catch (e) { |
| const timedOut = e && e.name === 'AbortError'; |
| if (uiModule) uiModule.showError(timedOut ? 'Saving draft timed out' : 'Failed to save draft'); |
| } finally { |
| clearTimeout(timeout); |
| if (btn) { btn.disabled = false; btn.textContent = 'Draft'; } |
| } |
| } |
|
|
| function _discardEmail() { |
| if (!activeDocId) return; |
| |
| _closeWithoutDeleting(true); |
| } |
|
|
| function _visibleDocIdsForCurrentSession() { |
| const curSession = sessionModule?.getCurrentSessionId() || ''; |
| const ids = []; |
| for (const [id, doc] of docs) { |
| if (doc.sessionId && curSession && doc.sessionId !== curSession) continue; |
| ids.push(id); |
| } |
| return ids; |
| } |
|
|
| function _detachActiveEmailForBackground(docId) { |
| if (!docId || !docs.has(docId)) return null; |
| saveCurrentToMap(); |
| const doc = docs.get(docId); |
| const snapshot = { id: docId, doc: { ...doc } }; |
| const wasActive = activeDocId === docId; |
| if (wasActive) saveDocument({ silent: true }).catch(() => {}); |
|
|
| const visibleBefore = _visibleDocIdsForCurrentSession(); |
| const idx = visibleBefore.indexOf(docId); |
| docs.delete(docId); |
| if (wasActive) activeDocId = null; |
|
|
| if (wasActive) { |
| const remaining = visibleBefore.filter(id => id !== docId && docs.has(id)); |
| const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null; |
| if (nextId) { |
| switchToDoc(nextId); |
| } else { |
| closePanel(); |
| } |
| } |
| renderTabs(); |
| _syncDocIndicator(); |
| return snapshot; |
| } |
|
|
| function _restoreDetachedEmailDoc(snapshot) { |
| if (!snapshot || !snapshot.id || !snapshot.doc) return; |
| if (!docs.has(snapshot.id)) docs.set(snapshot.id, snapshot.doc); |
| _ensureDocPaneMounted(); |
| switchToDoc(snapshot.id); |
| _syncDocIndicator(); |
| } |
|
|
| function _closeWithoutDeleting(deleteDoc = false) { |
| if (!activeDocId) return; |
| if (deleteDoc) { |
| fetch(`${API_BASE}/api/document/${activeDocId}`, { method: 'DELETE' }).catch(() => {}); |
| } |
| |
| saveCurrentToMap(); |
| if (!deleteDoc) { |
| saveDocument({ silent: true }).catch(() => {}); |
| } |
| docs.delete(activeDocId); |
| const remaining = Array.from(docs.keys()); |
| if (remaining.length > 0) { |
| switchToDoc(remaining[0]); |
| } else { |
| closePanel(); |
| } |
| renderTabs(); |
| } |
|
|
| async function _aiReply() { |
| const to = document.getElementById('doc-email-to')?.value?.trim() || ''; |
| const subject = document.getElementById('doc-email-subject')?.value?.trim() || ''; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| const currentBody = textarea.value || ''; |
| const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || ''; |
| const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || ''; |
| const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; |
| const cleanAiReplyText = (text) => { |
| if (!text) return ''; |
| let t = String(text); |
| const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i; |
| const close = /<<<\s*END\s*>>+/i; |
| const m = open.exec(t); |
| if (m) { |
| const rest = t.slice(m.index + m[0].length); |
| const c = close.exec(rest); |
| t = c ? rest.slice(0, c.index) : rest; |
| } |
| return t |
| .replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '') |
| .replace(/<<<\s*END\s*>>+/gi, '') |
| .trim(); |
| }; |
| const shouldUseFastAiReply = () => { |
| const text = `${subject}\n${currentBody}`.toLowerCase(); |
| if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) { |
| return false; |
| } |
| return currentBody.length < 2500; |
| }; |
|
|
| |
| let currentModel = ''; |
| let currentSessionId = ''; |
| try { |
| currentModel = sessionModule?.getCurrentModel() || ''; |
| currentSessionId = sessionModule?.getCurrentSessionId() || ''; |
| } catch (_) {} |
|
|
| const btn = document.getElementById('doc-email-ai-reply-btn'); |
| if (btn) { btn.disabled = true; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Drafting...'; } |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/email/ai-reply`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| to: to, |
| subject: subject, |
| original_body: currentBody, |
| model: currentModel, |
| session_id: currentSessionId, |
| message_id: inReplyTo, |
| uid: sourceUid, |
| folder: sourceFolder, |
| fast: shouldUseFastAiReply(), |
| }), |
| }); |
| const data = await res.json(); |
| if (data.success && data.reply) { |
| const cleanReply = cleanAiReplyText(data.reply); |
| const lines = currentBody.split('\n'); |
| const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:')); |
| let newBody = ''; |
| if (quoteIdx > 0) { |
| newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n'); |
| } else { |
| newBody = cleanReply + (currentBody ? '\n\n' + currentBody : ''); |
| } |
| await _streamEmailBodyText(textarea, newBody); |
| if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`); |
| } else { |
| if (uiModule) uiModule.showError(data.error || 'Failed to generate reply'); |
| } |
| } catch (e) { |
| if (uiModule) uiModule.showError('Failed to generate AI reply'); |
| } finally { |
| if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Reply'; } |
| } |
| } |
|
|
| async function _scheduleSend(anchorEl = null) { |
| const to = document.getElementById('doc-email-to')?.value?.trim(); |
| const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; |
| const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; |
| const subject = document.getElementById('doc-email-subject')?.value?.trim(); |
| const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); |
| const references = document.getElementById('doc-email-references')?.value?.trim(); |
| const _rich = _emailRichbodyActive(); |
| if (_rich) _syncEmailRichbody(_rich); |
| const body = (_rich |
| ? (_rich.innerText || _rich.textContent || '') |
| : (document.getElementById('doc-editor-textarea')?.value || '') |
| ).trim(); |
| const doc = docs.get(activeDocId); |
| const attachments = (doc?._composeAtts || []).map(a => a.token); |
|
|
| if (!to || !body) { |
| if (uiModule) uiModule.showError('To and body are required'); |
| return; |
| } |
| if (inReplyTo && !_emailReplyOwnText(body)) { |
| if (uiModule) uiModule.showError('Reply body is empty'); |
| return; |
| } |
| if (attachments.length === 0 && _bodyMentionsAttachment(body)) { |
| const proceed = await _confirmMissingAttachment(); |
| if (!proceed) return; |
| } |
|
|
| |
| const overlay = document.createElement('div'); |
| overlay.className = 'modal'; |
| overlay.style.display = 'flex'; |
| overlay.innerHTML = ` |
| <div class="modal-content schedule-send-modal" style="width:400px;max-width:92vw;"> |
| <div class="modal-header"> |
| <h4>Schedule Send</h4> |
| <button class="close-btn" id="sched-close" title="Close"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> |
| </div> |
| <div class="modal-body schedule-send-body"> |
| <label class="schedule-send-label">Quick presets</label> |
| <div class="schedule-send-presets"> |
| <button class="memory-toolbar-btn" data-preset="1h">In 1 hour</button> |
| <button class="memory-toolbar-btn" data-preset="3h">In 3 hours</button> |
| <button class="memory-toolbar-btn" data-preset="tomorrow">Tomorrow 9am</button> |
| <button class="memory-toolbar-btn" data-preset="monday">Monday 9am</button> |
| </div> |
| <label class="schedule-send-label" for="sched-datetime">Or pick a specific time</label> |
| <input type="datetime-local" id="sched-datetime" class="schedule-send-datetime" /> |
| </div> |
| <div class="modal-footer schedule-send-footer"> |
| <button class="memory-toolbar-btn" id="sched-cancel">Cancel</button> |
| <button class="memory-toolbar-btn schedule-send-confirm" id="sched-confirm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>Schedule</button> |
| </div> |
| </div> |
| `; |
| document.body.appendChild(overlay); |
| const modalContent = overlay.querySelector('.schedule-send-modal'); |
| const anchor = anchorEl || document.getElementById('doc-email-send-caret') || document.getElementById('doc-email-send-btn'); |
| if (modalContent && anchor) { |
| const rect = anchor.getBoundingClientRect(); |
| const gap = 8; |
| const width = Math.min(400, Math.max(280, window.innerWidth - 16)); |
| modalContent.style.width = `${width}px`; |
| modalContent.style.position = 'fixed'; |
| modalContent.style.margin = '0'; |
| modalContent.style.transform = 'none'; |
| const left = Math.max(8, Math.min(window.innerWidth - width - 8, rect.right - width)); |
| const belowTop = rect.bottom + gap; |
| const estimatedHeight = Math.min(320, window.innerHeight - 16); |
| const top = belowTop + estimatedHeight <= window.innerHeight - 8 |
| ? belowTop |
| : Math.max(8, rect.top - estimatedHeight - gap); |
| modalContent.style.left = `${left}px`; |
| modalContent.style.top = `${top}px`; |
| } |
|
|
| const dtInput = overlay.querySelector('#sched-datetime'); |
| |
| const now = new Date(Date.now() + 60 * 60 * 1000); |
| const pad = (n) => String(n).padStart(2, '0'); |
| dtInput.value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; |
|
|
| const escHandler = (e) => { if (e.key === 'Escape') cleanup(); }; |
| const cleanup = () => { |
| overlay.remove(); |
| document.removeEventListener('keydown', escHandler); |
| }; |
| overlay.querySelector('#sched-close').addEventListener('click', cleanup); |
| overlay.querySelector('#sched-cancel').addEventListener('click', cleanup); |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); }); |
| document.addEventListener('keydown', escHandler); |
|
|
| overlay.querySelectorAll('[data-preset]').forEach(btn => { |
| btn.addEventListener('click', () => { |
| const preset = btn.getAttribute('data-preset'); |
| const d = new Date(); |
| if (preset === '1h') d.setHours(d.getHours() + 1); |
| else if (preset === '3h') d.setHours(d.getHours() + 3); |
| else if (preset === 'tomorrow') { d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0); } |
| else if (preset === 'monday') { |
| const daysUntilMon = (8 - d.getDay()) % 7 || 7; |
| d.setDate(d.getDate() + daysUntilMon); |
| d.setHours(9, 0, 0, 0); |
| } |
| dtInput.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; |
| }); |
| }); |
|
|
| overlay.querySelector('#sched-confirm').addEventListener('click', async () => { |
| const localDt = dtInput.value; |
| if (!localDt) { if (uiModule) uiModule.showError('Please pick a time'); return; } |
| |
| const utcIso = new Date(localDt).toISOString(); |
| try { |
| const activeAccountId = await _resolveComposeSendAccountId(); |
| const res = await fetch(`${API_BASE}/api/email/schedule`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| to, cc: cc || null, bcc: bcc || null, subject, body, |
| in_reply_to: inReplyTo || null, |
| references: references || null, |
| attachments: attachments.length > 0 ? attachments : null, |
| send_at: utcIso, |
| account_id: activeAccountId, |
| }), |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| if (uiModule) uiModule.showToast(`Scheduled for ${new Date(localDt).toLocaleString()}`); |
| cleanup(); |
| |
| _closeWithoutDeleting(true); |
| } else { |
| if (uiModule) uiModule.showError(data.error || 'Failed to schedule'); |
| } |
| } catch (e) { |
| if (uiModule) uiModule.showError('Failed to schedule'); |
| } |
| }); |
| } |
|
|
| async function _markUnreadAndClose() { |
| const sourceUid = document.getElementById('doc-email-source-uid')?.value || ''; |
| const sourceFolder = document.getElementById('doc-email-source-folder')?.value || 'INBOX'; |
| if (sourceUid) { |
| try { |
| await fetch(`${API_BASE}/api/email/mark-unread/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }); |
| } catch (e) { console.error('Failed to mark unread:', e); } |
| } |
| _discardEmail(); |
| } |
|
|
| function switchToDoc(docId) { |
| if (!docs.has(docId)) return; |
| _hideLoadingOverlay(); |
| if (_diffModeActive) exitDiffMode(true); |
|
|
| |
| saveCurrentToMap(); |
|
|
| |
| const prevId = activeDocId; |
| if (prevId && prevId !== docId && docs.has(prevId)) { |
| const prev = docs.get(prevId); |
| if (!(prev.content || '').trim() && !(prev.title || '').trim()) { |
| fetch(`${API_BASE}/api/document/${prevId}`, { method: 'DELETE' }).catch(() => {}); |
| docs.delete(prevId); |
| _syncDocIndicator(); |
| } |
| } |
|
|
| activeDocId = docId; |
| clearSelection(); |
| const doc = docs.get(docId); |
|
|
| |
| const titleInput = document.getElementById('doc-title-input'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const langSelect = document.getElementById('doc-language-select'); |
| const badge = document.getElementById('doc-version-badge'); |
|
|
| if (titleInput) titleInput.value = doc.title || ''; |
| |
| if (textarea && doc.language !== 'email') textarea.value = doc.content || ''; |
| if (langSelect) langSelect.value = doc.language || 'markdown'; |
| if (badge) { const _v = doc.version || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; } |
| { const _v = doc.version || 1; const _dbtn = document.getElementById('doc-diff-toggle-btn'); if (_dbtn) _dbtn.style.display = _v > 1 ? '' : 'none'; } |
| syncHighlighting(); |
| |
| requestAnimationFrame(() => { |
| const ta2 = document.getElementById('doc-editor-textarea'); |
| const code2 = document.getElementById('doc-editor-code'); |
| const pre2 = document.getElementById('doc-editor-highlight'); |
| if (ta2 && code2 && pre2) { |
| code2.style.minHeight = ta2.scrollHeight + 'px'; |
| pre2.scrollTop = ta2.scrollTop; |
| } |
| }); |
|
|
| |
| if (!doc.userSetLanguage && !doc.language) { |
| setTimeout(attemptAutoDetect, 100); |
| } |
|
|
| |
| |
| |
| const isMd = (doc.language || 'markdown') === 'markdown'; |
| const isPdf = _isFormBackedDoc(doc.content || ''); |
|
|
| |
| |
| |
| |
| if (isPdf && !doc._ocrTriggered) { |
| doc._ocrTriggered = true; |
| (async () => { |
| try { |
| const r = await fetch(`${API_BASE}/api/document/${docId}/extract-pdf-text`, { method: 'POST', credentials: 'same-origin' }); |
| if (!r.ok) return; |
| const j = await r.json().catch(() => ({})); |
| if (j && j.extracted) { |
| |
| |
| const dr = await fetch(`${API_BASE}/api/document/${docId}`, { credentials: 'same-origin' }); |
| if (dr.ok) { |
| const full = await dr.json(); |
| const cached = docs.get(docId); |
| if (cached && full && full.current_content) { |
| cached.content = full.current_content; |
| } |
| } |
| } |
| } catch (_) {} |
| })(); |
| } |
| const mdToolbar = document.getElementById('doc-md-toolbar'); |
| if (mdToolbar) { |
| |
| |
| |
| mdToolbar.style.display = ''; |
| if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); |
| } |
| |
| document.querySelectorAll('.md-toolbar-pdf-only').forEach(el => { |
| el.style.display = isPdf ? '' : 'none'; |
| }); |
| |
| |
| const _fsBtn = document.getElementById('doc-fontsize-btn'); |
| if (_fsBtn) _fsBtn.style.display = isPdf ? 'none' : ''; |
| |
| const isCsv = doc.language === 'csv'; |
| const csvPreview = document.getElementById('doc-csv-preview'); |
| if (!isCsv) { |
| if (csvPreview) csvPreview.style.display = 'none'; |
| } else { |
| |
| requestAnimationFrame(() => toggleCsvPreview()); |
| } |
|
|
| |
| exitHtmlPreview(); |
|
|
| |
| |
| |
| const isEmail = doc.language === 'email'; |
| if (isEmail) { |
| _setMarkdownPreviewActive(false, { remember: false }); |
| _showEmailFields(doc); |
| } else { |
| _hideEmailFields(); |
| const wantsMarkdownPreview = (doc.language || 'markdown') === 'markdown' && doc._markdownPreviewActive === true; |
| _setMarkdownPreviewActive(wantsMarkdownPreview, { remember: false }); |
| } |
|
|
| |
| const vp = document.getElementById('doc-version-panel'); |
| if (vp) vp.classList.add('hidden'); |
|
|
| renderTabs(); |
| _syncHeaderActions(); |
|
|
| |
| if (_activeSuggestions.length === 0) { |
| _restoreSuggestionsFromStorage(docId); |
| } |
|
|
| } |
|
|
| |
| |
| |
| function _detachDocFromSession(docId, { toast = false } = {}) { |
| const doc = docs.get(docId); |
| const hasContent = doc && doc.content && doc.content.trim().length > 0; |
| if (hasContent) { |
| fetch(`${API_BASE}/api/document/${docId}`, { |
| method: 'PATCH', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ session_id: '' }), |
| }).then(() => { |
| if (toast && uiModule) uiModule.showToast('Document unlinked from session'); |
| }).catch(() => {}); |
| } else { |
| fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE' }).catch(() => {}); |
| } |
| docs.delete(docId); |
| _syncDocIndicator(); |
| } |
|
|
| async function closeTab(docId) { |
| |
| saveCurrentToMap(); |
| _detachDocFromSession(docId, { toast: true }); |
| |
| const curSession = sessionModule?.getCurrentSessionId() || ''; |
| let nextId = null; |
| for (const [id, d] of docs) { |
| if (!d.sessionId || !curSession || d.sessionId === curSession) { |
| nextId = id; |
| break; |
| } |
| } |
| if (!nextId) { |
| activeDocId = null; |
| closePanel(); |
| return; |
| } |
| if (activeDocId === docId) { |
| switchToDoc(nextId); |
| } else { |
| renderTabs(); |
| } |
| } |
|
|
| |
| let _autoCreating = false; |
| |
| |
| |
| |
| let _creatingDoc = false; |
| async function _autoCreateFromInput(content) { |
| if (_autoCreating) return; |
| _autoCreating = true; |
| try { |
| let sessionId = _lastSessionId |
| || (sessionModule && sessionModule.getCurrentSessionId()); |
| if (!sessionId) { |
| sessionId = await _autoCreateSession(); |
| } |
| const res = await fetch(`${API_BASE}/api/document`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ session_id: sessionId, title: '', content }), |
| }); |
| const doc = await res.json(); |
| addDocToTabs(doc, sessionId); |
| |
| const d = docs.get(doc.id); |
| if (d) d.content = content; |
| activeDocId = doc.id; |
| |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) { |
| textarea.placeholder = 'Document content...'; |
| } |
| syncHighlighting(); |
| renderTabs(); |
| |
| setTimeout(attemptAutoDetect, 100); |
| setTimeout(() => autoTitleFromContent(content), 300); |
| |
| clearTimeout(_autoSaveDebounce); |
| _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000); |
| } catch (e) { |
| console.error('Failed to auto-create document from input:', e); |
| } finally { |
| _autoCreating = false; |
| } |
| } |
|
|
| |
| function saveCurrentToMap() { |
| if (!activeDocId || !docs.has(activeDocId)) return; |
| const doc = docs.get(activeDocId); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const titleInput = document.getElementById('doc-title-input'); |
| const langSelect = document.getElementById('doc-language-select'); |
| if (titleInput) doc.title = titleInput.value; |
| if (langSelect) doc.language = langSelect.value; |
| |
| if (doc.language === 'email' && textarea) { |
| const to = document.getElementById('doc-email-to')?.value || ''; |
| const cc = document.getElementById('doc-email-cc')?.value || ''; |
| const bcc = document.getElementById('doc-email-bcc')?.value || ''; |
| const subject = document.getElementById('doc-email-subject')?.value || ''; |
| const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value || ''; |
| const references = document.getElementById('doc-email-references')?.value || ''; |
| const sourceUid = document.getElementById('doc-email-source-uid')?.value || ''; |
| const sourceFolder = document.getElementById('doc-email-source-folder')?.value || ''; |
| |
| |
| |
| const _rich = document.getElementById('doc-email-richbody'); |
| const _emailBody = (_rich && _rich.style.display !== 'none') ? _rich.innerHTML : textarea.value; |
| doc.content = _buildEmailContent(to, subject, inReplyTo, references, _emailBody, sourceUid, sourceFolder, cc, bcc); |
| } else if (textarea) { |
| |
| |
| |
| |
| if (!(textarea.value === '' && _isFormBackedDoc(doc.content))) { |
| doc.content = textarea.value; |
| } |
| } |
| } |
|
|
| |
|
|
| export function openPanel() { |
| if (isOpen) return; |
| |
| |
| |
| document.getElementById('doc-editor-pane')?.remove(); |
| document.getElementById('doc-divider')?.remove(); |
| |
| |
| |
| if (Modals.isRegistered('doc-panel') && Modals.isMinimized('doc-panel')) { |
| _minimizedDocId = null; |
| Modals.unregister('doc-panel'); |
| } |
| isOpen = true; |
| |
| |
| document.body.classList.remove('email-front'); |
| _ensureAgentMode(); |
| _markDocVisibleState(_lastSessionId, 'open'); |
|
|
| const container = document.getElementById('chat-container'); |
| if (!container) return; |
|
|
| document.body.classList.add('doc-view'); |
|
|
| |
| const toggleBtn = document.getElementById('overflow-doc-btn'); |
| if (toggleBtn) toggleBtn.classList.add('active'); |
| const docInd = document.getElementById('doc-indicator-btn'); |
| if (docInd) docInd.classList.add('active'); |
|
|
| |
| |
| const divider = document.createElement('div'); |
| divider.className = 'doc-divider'; |
| divider.id = 'doc-divider'; |
| |
| |
| |
| |
| |
| |
| |
| |
| divider.innerHTML = '<button type="button" class="doc-divider-collapse" title="Collapse panel" data-mode="collapse"><span>›</span></button>' + |
| '<button type="button" class="doc-divider-hide" title="Hide panel" aria-label="Hide panel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'; |
| const _divHide = divider.querySelector('.doc-divider-hide'); |
| if (_divHide) { |
| _divHide.addEventListener('mousedown', (e) => e.stopPropagation()); |
| _divHide.addEventListener('click', (e) => { e.stopPropagation(); closePanel('down'); }); |
| } |
|
|
| |
| const pane = document.createElement('div'); |
| pane.id = 'doc-editor-pane'; |
| pane.className = 'doc-editor-pane'; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| { |
| let _kbBtn = null; |
| pane.addEventListener('pointerdown', (e) => { |
| _kbBtn = null; |
| if (e.pointerType !== 'touch') return; |
| const btn = e.target.closest && e.target.closest('button'); |
| if (!btn) return; |
| const ae = document.activeElement; |
| if (!(ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA'))) return; |
| e.preventDefault(); |
| _kbBtn = btn; |
| }, true); |
| pane.addEventListener('pointerup', (e) => { |
| const btn = _kbBtn; _kbBtn = null; |
| if (!btn) return; |
| if (e.target.closest && e.target.closest('button') === btn) { |
| btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); |
| } |
| }, true); |
| pane.addEventListener('pointercancel', () => { _kbBtn = null; }, true); |
| } |
| pane.innerHTML = ` |
| <input type="hidden" id="doc-title-input" value="" /> |
| <div class="doc-mobile-grabber" id="doc-mobile-grabber" aria-hidden="true"></div> |
| <div class="doc-editor-header" id="doc-editor-actions"> |
| <button id="doc-undo-btn" class="doc-action-icon-btn" title="Undo (Ctrl+Z)" style="gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg><span style="font-size:11px;">Undo</span></button> |
| <button id="doc-header-preview-btn" class="doc-action-icon-btn" title="Run / Preview" style="display:none;opacity:0.85;gap:4px;"></button> |
| <span id="doc-stream-indicator" class="doc-stream-indicator" style="display:none"><span class="doc-stream-dot"></span> editing</span> |
| <span id="doc-version-badge" class="doc-version-badge" title="Version history" style="display:none">v1</span> |
| <span style="flex:1"></span> |
| <button id="doc-export-pdf-btn" class="doc-action-icon-btn" title="Export PDF" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg> <span style="font-size:11px;">Export PDF</span></button> |
| <button id="doc-pdf-view-btn" class="doc-action-icon-btn" title="Toggle PDF view" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <span style="font-size:11px;">PDF</span></button> |
| <select id="doc-language-select" class="doc-language-select"> |
| <option value="">type</option> |
| <option value="python">python</option> |
| <option value="javascript">javascript</option> |
| <option value="typescript">typescript</option> |
| <option value="html">html</option> |
| <option value="css">css</option> |
| <option value="markdown">markdown</option> |
| <option value="json">json</option> |
| <option value="yaml">yaml</option> |
| <option value="bash">bash</option> |
| <option value="sql">sql</option> |
| <option value="rust">rust</option> |
| <option value="go">go</option> |
| <option value="java">java</option> |
| <option value="c">c</option> |
| <option value="cpp">c++</option> |
| <option value="csharp">c#</option> |
| <option value="xml">xml</option> |
| <option value="svg">svg</option> |
| <option value="toml">toml</option> |
| <option value="ini">ini</option> |
| <option value="ruby">ruby</option> |
| <option value="php">php</option> |
| <option value="csv">csv</option> |
| <option value="email">email</option> |
| <option value="pdf">pdf</option> |
| </select> |
| <!-- Close + Copy/Export moved to the bottom action footer (#doc-actions-footer) |
| so regular docs match the email footer layout. --> |
| </div> |
| <div class="doc-tab-bar" id="doc-tab-bar"></div> |
| <div id="doc-email-header" class="doc-email-header" style="display:none"> |
| <button type="button" id="doc-email-collapse-btn" class="doc-email-collapse-btn" title="Hide email fields" aria-expanded="true"> |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg> |
| <span id="doc-email-collapse-summary" class="doc-email-collapse-summary">No recipient · No subject</span> |
| </button> |
| <div id="doc-email-fields" class="doc-email-fields"> |
| <div class="email-field" style="position:relative"> |
| <label>To</label> |
| <input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" /> |
| <div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div> |
| <button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button> |
| </div> |
| <div class="email-field" id="doc-email-cc-row" style="display:none;position:relative"> |
| <label>Cc</label> |
| <input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" /> |
| <div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div> |
| </div> |
| <div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative"> |
| <label>Bcc</label> |
| <input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" /> |
| <div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div> |
| </div> |
| <div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div> |
| <div id="doc-email-attachments" class="email-attachments" style="display:none"></div> |
| <div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div> |
| </div> |
| <input type="hidden" id="doc-email-in-reply-to" /> |
| <input type="hidden" id="doc-email-references" /> |
| <input type="hidden" id="doc-email-source-uid" /> |
| <input type="hidden" id="doc-email-source-folder" /> |
| <input type="file" id="doc-email-file-input" multiple style="display:none" /> |
| </div> |
| <div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none"> |
| <div class="md-toolbar-items" id="md-toolbar-items"> |
| <span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview"> |
| <button type="button" class="md-view-opt" data-mdview="edit" title="Edit source"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button> |
| <button type="button" class="md-view-opt" data-mdview="preview" title="Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button> |
| </span> |
| <span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run"> |
| <button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button> |
| <button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button> |
| </span> |
| <button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button> |
| <button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button> |
| <span class="md-toolbar-sep"></span> |
| <button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button> |
| <button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button> |
| <button type="button" data-md="strike" title="Strikethrough"><s>S</s></button> |
| <span class="md-toolbar-sep"></span> |
| <button type="button" class="md-dd-toggle" data-dd="heading" title="Heading"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button> |
| <button type="button" class="md-dd-toggle" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button> |
| <span class="md-toolbar-sep"></span> |
| <button type="button" data-md="link" title="Link"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button> |
| <button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Attach files"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button> |
| <button type="button" class="md-dd-toggle md-toolbar-email-hide" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button> |
| <button type="button" data-md="hr" title="Horizontal rule">—</button> |
| <span class="md-toolbar-sep"></span> |
| <span id="md-toolbar-emoji-slot"></span> |
| <span class="md-toolbar-sep md-toolbar-pdf-only" style="display:none"></span> |
| <button type="button" id="doc-pdf-add-text-btn" class="md-toolbar-pdf-only" title="Add text box (then click on PDF)" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button> |
| <button type="button" id="doc-pdf-add-check-btn" class="md-toolbar-pdf-only" title="Add checkmark (then click on PDF)" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></button> |
| <button type="button" id="doc-pdf-add-sign-btn" class="md-toolbar-pdf-only" title="Add signature (then click on PDF)" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3l6 6-9 9-3-3z"/><path d="M9 15l-3 1 1-3"/><path d="M4 18l3-3"/><path d="M3 20l3-3"/><path d="M5 22l3-3"/></svg><span class="doc-pdf-sign-label">sign</span></button> |
| <button type="button" id="doc-pdf-refresh-btn" class="md-toolbar-pdf-only" title="Reload PDF view" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button> |
| </div> |
| <div class="md-toolbar-overflow-wrapper" id="md-toolbar-overflow-wrapper" style="display:none"> |
| <button class="md-toolbar-overflow-toggle" id="md-toolbar-overflow-toggle" title="More formatting"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button> |
| <div class="md-toolbar-overflow-menu" id="md-toolbar-overflow-menu"></div> |
| </div> |
| <button type="button" class="md-scroll-arrow md-scroll-left" id="md-scroll-left" title="Scroll left" style="display:none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button> |
| <button type="button" class="md-scroll-arrow md-scroll-right" id="md-scroll-right" title="Scroll right" style="display:none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button> |
| </div> |
| <div id="doc-find-bar" class="doc-find-bar" style="display:none"> |
| <input id="doc-find-input" class="doc-find-input" type="text" placeholder="Find..." /> |
| <span id="doc-find-count" class="doc-find-count"></span> |
| <button id="doc-find-prev" class="doc-find-nav" title="Previous">↑</button> |
| <button id="doc-find-next" class="doc-find-nav" title="Next">↓</button> |
| <button id="doc-find-close" class="doc-find-close" title="Close">×</button> |
| </div> |
| <div id="doc-editor-wrap" class="doc-editor-wrap"> |
| <div id="doc-line-numbers" class="doc-line-numbers">1</div> |
| <pre id="doc-editor-highlight" class="doc-editor-highlight"><code id="doc-editor-code"></code></pre> |
| <textarea id="doc-editor-textarea" class="doc-editor-textarea" placeholder="Document content..." spellcheck="false"></textarea> |
| </div> |
| <!-- WYSIWYG email body. In email mode this replaces the source editor: |
| B/I/S act on the live text (execCommand), and on send its HTML becomes |
| the email's HTML part. Its plain text is mirrored into the textarea so |
| the existing send/draft/change-detection paths keep working. --> |
| <div id="doc-email-richbody" class="doc-email-richbody" contenteditable="true" spellcheck="true" style="display:none" data-no-swipe-dismiss></div> |
| <div id="doc-email-actions" class="doc-email-actions" style="display:none"> |
| <button id="doc-email-discard-btn" class="email-discard-btn" title="Close email" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Close</span></button> |
| <span style="flex:1"></span> |
| <div class="email-send-split"> |
| <button id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button> |
| <button id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button> |
| <div id="doc-email-more-menu" class="email-more-menu" style="display:none"> |
| <div class="dropdown-item-compact" id="doc-email-draft-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Save Draft</div> |
| <div class="dropdown-item-compact" id="doc-email-schedule-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>Schedule Send...</div> |
| <div class="dropdown-item-compact" id="doc-email-unread-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg></span>Mark Unread</div> |
| </div> |
| </div> |
| </div> |
| <div id="doc-md-preview" class="doc-md-preview" style="display:none"></div> |
| <div id="doc-csv-preview" class="doc-csv-preview" style="display:none"></div> |
| <iframe id="doc-html-preview" class="doc-html-preview" sandbox="allow-scripts allow-modals" style="display:none"></iframe> |
| <div id="doc-pdf-view" style="display:none;width:100%;flex:1;min-height:0;overflow:auto;background:#525659;padding:20px 0;position:relative;"> |
| <div id="doc-pdf-save-pill" style="display:none;position:absolute;top:8px;right:14px;padding:4px 10px;border-radius:12px;font-size:11px;z-index:5;pointer-events:none;background:transparent;color:transparent;"></div> |
| </div> |
| <!-- Action footer sits AFTER all the content/preview panes so it stays |
| pinned to the bottom no matter which pane (editor / md-preview / |
| csv / html / pdf) is the one growing to fill. --> |
| <div id="doc-actions-footer" class="doc-email-actions"> |
| <span class="email-send-split" id="doc-copy-export-split"> |
| <button type="button" id="doc-footer-copy-btn" class="email-send-btn email-send-main" title="Copy document"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy</button> |
| <button type="button" id="doc-footer-export-btn" class="email-send-btn email-send-caret" title="Export as…" aria-label="Export options"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg></button> |
| </span> |
| </div> |
| <div id="doc-version-panel" class="doc-version-panel hidden"> |
| <div class="doc-version-header"> |
| <span>Version History</span> |
| <button id="doc-version-close" class="doc-action-icon-btn" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> |
| </div> |
| <div id="doc-version-list" class="doc-version-list"></div> |
| </div> |
| <div id="doc-mobile-footer" class="doc-mobile-footer"> |
| <button id="doc-mobile-close" class="doc-mobile-footer-btn" type="button">Unlink</button> |
| <span style="flex:1"></span> |
| <button id="doc-mobile-copy" class="doc-mobile-footer-btn" type="button">Copy</button> |
| </div> |
| `; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| { |
| const _footer = pane.querySelector('#doc-actions-footer'); |
| const _split = _footer && _footer.querySelector('#doc-copy-export-split'); |
| const _undo = pane.querySelector('#doc-undo-btn'); |
| const _lang = pane.querySelector('#doc-language-select'); |
| const _preview = pane.querySelector('#doc-header-preview-btn'); |
| const _exportPdf = pane.querySelector('#doc-export-pdf-btn'); |
| const _pdfView = pane.querySelector('#doc-pdf-view-btn'); |
| if (_footer && _split) { |
| |
| |
| |
| if (_undo) _footer.insertBefore(_undo, _footer.firstChild); |
| const _anchor = _undo; |
| if (_preview && _anchor) _anchor.after(_preview); |
| if (_lang) _split.before(_lang); |
| |
| |
| |
| |
| const _streamInd = pane.querySelector('#doc-stream-indicator'); |
| const _versionBadge = pane.querySelector('#doc-version-badge'); |
| if (_split) { |
| if (_pdfView) _split.before(_pdfView); |
| if (_exportPdf) _split.before(_exportPdf); |
| if (_versionBadge) _split.before(_versionBadge); |
| if (_streamInd) _split.before(_streamInd); |
| } |
| } |
| |
| |
| |
| if (_footer) _footer.addEventListener('pointerdown', (e) => { |
| if (!e.target.closest('button, select')) return; |
| const _ta = document.getElementById('doc-editor-textarea'); |
| if (_ta && document.activeElement === _ta) _ta.blur(); |
| }); |
| } |
|
|
| |
| |
| const sidebar = document.getElementById('sidebar'); |
| const isRight = sidebar && sidebar.classList.contains('right-side'); |
| if (isRight) { |
| pane.classList.add('doc-left'); |
| container.parentNode.insertBefore(pane, container); |
| container.parentNode.insertBefore(divider, container); |
| } else { |
| pane.classList.remove('doc-left'); |
| container.after(divider); |
| divider.after(pane); |
| } |
|
|
| |
| const fromLeft = pane.classList.contains('doc-left'); |
| pane.style.transform = fromLeft ? 'translateX(-40px)' : 'translateX(40px)'; |
| pane.style.opacity = '0'; |
| requestAnimationFrame(() => { |
| pane.style.transition = 'transform 0.15s cubic-bezier(0.22,1,0.36,1), opacity 0.12s ease-out'; |
| pane.style.transform = 'translateX(0)'; |
| pane.style.opacity = '1'; |
| pane.addEventListener('transitionend', () => { |
| pane.style.transition = ''; |
| pane.style.transform = ''; |
| pane.style.opacity = ''; |
| }, { once: true }); |
| }); |
|
|
| |
| initDividerDrag(divider, pane, isRight); |
| |
| |
| |
| |
| |
| |
| |
| const _divCollapse = divider.querySelector('.doc-divider-collapse'); |
| if (_divCollapse) { |
| _divCollapse.addEventListener('mousedown', (e) => e.stopPropagation()); |
| let _dragging = false; |
| _divCollapse.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| if (_dragging) { _dragging = false; return; } |
| const mode = _divCollapse.dataset.mode; |
| if (mode === 'fullscreen' || mode === 'unfullscreen') toggleFullscreen(); |
| else closePanel('down'); |
| }); |
| const HYSTERESIS = 24; |
| const _applyMode = (ev) => { |
| |
| |
| |
| const isFull = pane.classList.contains('doc-fullscreen'); |
| if (isFull) { |
| if (_divCollapse.dataset.mode !== 'unfullscreen') { |
| _divCollapse.dataset.mode = 'unfullscreen'; |
| _divCollapse.title = 'Exit fullscreen'; |
| } |
| return; |
| } |
| if (!ev) return; |
| const rect = divider.getBoundingClientRect(); |
| const midX = (rect.left + rect.right) / 2; |
| const cur = _divCollapse.dataset.mode; |
| if (ev.clientX > midX + HYSTERESIS && cur !== 'collapse') { |
| _divCollapse.dataset.mode = 'collapse'; |
| _divCollapse.title = 'Collapse panel'; |
| } else if (ev.clientX < midX - HYSTERESIS && cur !== 'fullscreen') { |
| _divCollapse.dataset.mode = 'fullscreen'; |
| _divCollapse.title = 'Fullscreen'; |
| } |
| }; |
| const _onMove = (ev) => _applyMode(ev); |
| document.addEventListener('pointermove', _onMove, { passive: true }); |
| |
| const _classObs = new MutationObserver(() => _applyMode()); |
| _classObs.observe(pane, { attributes: true, attributeFilter: ['class'] }); |
|
|
| |
| |
| |
| |
| const DRAG_THRESHOLD = 4; |
| let _startY = 0, _moved = false, _pid = null; |
| _divCollapse.addEventListener('pointerdown', (ev) => { |
| if (ev.button !== 0 && ev.pointerType === 'mouse') return; |
| _startY = ev.clientY; |
| _moved = false; |
| _pid = ev.pointerId; |
| _divCollapse.setPointerCapture?.(_pid); |
| ev.preventDefault(); |
| }); |
| _divCollapse.addEventListener('pointermove', (ev) => { |
| if (_pid === null) return; |
| const dy = ev.clientY - _startY; |
| if (!_moved && Math.abs(dy) < DRAG_THRESHOLD) return; |
| _moved = true; |
| _dragging = true; |
| const rect = divider.getBoundingClientRect(); |
| if (!rect.height) return; |
| const pct = Math.max(6, Math.min(94, ((ev.clientY - rect.top) / rect.height) * 100)); |
| _divCollapse.style.top = pct + '%'; |
| }); |
| const _endDrag = () => { |
| if (_pid !== null) { |
| try { _divCollapse.releasePointerCapture?.(_pid); } catch {} |
| _pid = null; |
| } |
| }; |
| _divCollapse.addEventListener('pointerup', _endDrag); |
| _divCollapse.addEventListener('pointercancel', _endDrag); |
|
|
| const _obs = new MutationObserver(() => { |
| if (!document.body.contains(divider)) { |
| document.removeEventListener('pointermove', _onMove); |
| _classObs.disconnect(); |
| _obs.disconnect(); |
| } |
| }); |
| _obs.observe(document.body, { childList: true, subtree: true }); |
| } |
|
|
| |
| _wireSwipeDismiss(document.getElementById('doc-mobile-grabber')); |
| document.getElementById('doc-mobile-grabber')?.addEventListener('click', () => closePanel('down')); |
|
|
| |
| document.getElementById('doc-close-btn')?.addEventListener('click', () => closePanel('down')); |
| document.getElementById('doc-footer-close-btn')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); }); |
| document.getElementById('doc-import-btn')?.addEventListener('click', () => openLibrary()); |
| document.getElementById('doc-footer-copy-btn')?.addEventListener('click', (e) => { |
| if (e.currentTarget.dataset.mode === 'reply') { if (activeDocId) _sendSignedReply(activeDocId); } |
| else copyDocument(); |
| }); |
| document.getElementById('doc-footer-export-btn')?.addEventListener('click', (e) => showExportMenu(null, e.currentTarget.getBoundingClientRect())); |
| |
| |
| document.getElementById('doc-mobile-close')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); }); |
| document.getElementById('doc-mobile-copy')?.addEventListener('click', () => copyDocument()); |
| |
| document.getElementById('doc-version-badge').addEventListener('click', toggleVersionHistory); |
| document.getElementById('doc-version-close').addEventListener('click', _closeVersionPanel); |
| |
| const _syncLangIcon = () => { |
| const iconEl = document.getElementById('doc-language-icon'); |
| const v = document.getElementById('doc-language-select')?.value || ''; |
| if (iconEl) iconEl.innerHTML = v ? langIcon(v, 14, { style: 'opacity:0.75;' }) : ''; |
| }; |
| |
| |
| (function _interceptLangSelectValue() { |
| const ls = document.getElementById('doc-language-select'); |
| if (!ls) return; |
| const desc = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); |
| if (!desc || !desc.set) return; |
| Object.defineProperty(ls, 'value', { |
| configurable: true, |
| get() { return desc.get.call(this); }, |
| set(v) { desc.set.call(this, v); _syncLangIcon(); _syncLangPicker(); }, |
| }); |
| _syncLangIcon(); |
| })(); |
|
|
| |
| |
| |
| |
| |
| |
| let _syncLangPicker = () => {}; |
| (function _initLangPicker() { |
| const ls = document.getElementById('doc-language-select'); |
| if (!ls || ls.dataset.pickerWired === '1') return; |
| ls.dataset.pickerWired = '1'; |
|
|
| const trigger = document.createElement('button'); |
| trigger.type = 'button'; |
| trigger.id = 'doc-langpicker-trigger'; |
| trigger.className = 'doc-langpicker-trigger'; |
| trigger.setAttribute('aria-haspopup', 'listbox'); |
| trigger.setAttribute('aria-expanded', 'false'); |
|
|
| const menu = document.createElement('div'); |
| menu.id = 'doc-langpicker-menu'; |
| menu.className = 'doc-langpicker-menu'; |
| menu.setAttribute('role', 'listbox'); |
| menu.style.display = 'none'; |
|
|
| |
| |
| const _buildMenu = () => { |
| menu.innerHTML = ''; |
| for (const opt of ls.options) { |
| const row = document.createElement('button'); |
| row.type = 'button'; |
| row.className = 'doc-langpicker-item'; |
| row.dataset.value = opt.value; |
| row.setAttribute('role', 'option'); |
| const ic = opt.value |
| ? langIcon(opt.value, 14, { style: 'opacity:0.85;' }) |
| |
| |
| |
| : '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.5;"><circle cx="12" cy="12" r="3"/></svg>'; |
| row.innerHTML = ic + |
| `<span class="doc-langpicker-label">${uiModule.esc(opt.textContent || opt.value)}</span>`; |
| row.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| if (ls.value !== opt.value) { |
| ls.value = opt.value; |
| ls.dispatchEvent(new Event('change', { bubbles: true })); |
| } |
| _close(); |
| }); |
| menu.appendChild(row); |
| } |
| }; |
| _buildMenu(); |
|
|
| _syncLangPicker = () => { |
| const v = ls.value || ''; |
| const sel = Array.from(ls.options).find(o => o.value === v) || ls.options[0]; |
| const ic = v |
| ? langIcon(v, 14, { style: 'opacity:0.85;flex-shrink:0;' }) |
| |
| : '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.5;flex-shrink:0;"><circle cx="12" cy="12" r="3"/></svg>'; |
| trigger.innerHTML = ic + |
| `<span class="doc-langpicker-label">${uiModule.esc(sel?.textContent || 'type')}</span>` + |
| '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;opacity:0.6;"><polyline points="6 9 12 15 18 9"/></svg>'; |
| |
| menu.querySelectorAll('.doc-langpicker-item').forEach(r => { |
| r.classList.toggle('is-selected', r.dataset.value === v); |
| }); |
| }; |
|
|
| const _close = () => { |
| menu.style.display = 'none'; |
| trigger.setAttribute('aria-expanded', 'false'); |
| document.removeEventListener('click', _outsideClick, true); |
| document.removeEventListener('keydown', _escKey, true); |
| }; |
| const _outsideClick = (e) => { |
| if (!menu.contains(e.target) && e.target !== trigger) _close(); |
| }; |
| const _escKey = (e) => { |
| if (e.key !== 'Escape' || menu.style.display === 'none') return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| e.stopImmediatePropagation?.(); |
| _close(); |
| }; |
|
|
| trigger.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const open = menu.style.display !== 'none'; |
| if (open) { _close(); return; } |
| |
| |
| const r = trigger.getBoundingClientRect(); |
| menu.style.display = 'block'; |
| menu.style.position = 'fixed'; |
| menu.style.left = r.left + 'px'; |
| menu.style.top = (r.bottom + 4) + 'px'; |
| menu.style.minWidth = r.width + 'px'; |
| |
| requestAnimationFrame(() => { |
| const mr = menu.getBoundingClientRect(); |
| if (mr.bottom > window.innerHeight - 8) { |
| menu.style.top = Math.max(8, r.top - mr.height - 4) + 'px'; |
| } |
| }); |
| trigger.setAttribute('aria-expanded', 'true'); |
| document.addEventListener('click', _outsideClick, true); |
| document.addEventListener('keydown', _escKey, true); |
| }); |
|
|
| |
| |
| |
| ls.classList.add('doc-langpicker-native-hidden'); |
| const iconSpan = document.getElementById('doc-language-icon'); |
| if (iconSpan) iconSpan.remove(); |
| ls.parentNode.insertBefore(trigger, ls); |
| |
| document.body.appendChild(menu); |
|
|
| _syncLangPicker(); |
| })(); |
| document.getElementById('doc-language-select').addEventListener('change', () => { |
| _syncLangIcon(); |
| _syncLangPicker(); |
| const val = document.getElementById('doc-language-select').value; |
| |
| |
| const live = document.getElementById('doc-editor-textarea')?.value |
| || docs.get(activeDocId)?.content || ''; |
| if (_isFormBackedDoc(live) && (val === 'pdf' || val === 'markdown')) { |
| _setPdfViewActive(val === 'pdf'); |
| return; |
| } |
| |
| if (activeDocId && docs.has(activeDocId)) { |
| docs.get(activeDocId).userSetLanguage = (val !== ''); |
| } |
| updateLanguage(); |
| syncHighlighting(); |
| |
| const lang = document.getElementById('doc-language-select').value; |
| const mdToolbar = document.getElementById('doc-md-toolbar'); |
| if (mdToolbar) { |
| |
| |
| mdToolbar.style.display = ''; |
| if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); |
| } |
| |
| if (lang !== 'markdown') { |
| _setMarkdownPreviewActive(false); |
| } |
| |
| if (lang !== 'csv') { |
| const csvPreview = document.getElementById('doc-csv-preview'); |
| const wrap2 = document.getElementById('doc-editor-wrap'); |
| if (csvPreview) csvPreview.style.display = 'none'; |
| if (wrap2) wrap2.style.display = ''; |
| } |
| |
| if (!_isRenderLang(lang)) exitHtmlPreview(); |
| |
| if (lang === 'email') { |
| const doc = activeDocId && docs.get(activeDocId); |
| if (doc) _showEmailFields(doc); |
| } else { |
| _hideEmailFields(); |
| } |
| |
| _syncHeaderActions(); |
| }); |
|
|
| |
| |
| const emojiSlot = document.getElementById('md-toolbar-emoji-slot'); |
| if (emojiSlot && !emojiSlot.querySelector('.emoji-picker-btn')) { |
| |
| |
| emojiSlot.appendChild(emojiPicker.createEmojiButton( |
| () => _emailRichbodyActive() || document.getElementById('doc-editor-textarea') |
| )); |
| } |
|
|
| document.getElementById('doc-email-send-btn')?.addEventListener('click', () => { |
| |
| const _m = document.getElementById('doc-email-more-menu'); |
| if (_m) _m.style.display = 'none'; |
| document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false'); |
| _sendEmail(); |
| }); |
|
|
| |
| |
| if (!window._emailCtrlEnterBound) { |
| window._emailCtrlEnterBound = true; |
| document.addEventListener('keydown', (e) => { |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
| const doc = activeDocId && docs.get(activeDocId); |
| if (doc && doc.language === 'email' && isOpen) { |
| e.preventDefault(); |
| _sendEmail(); |
| } |
| } |
| }); |
| } |
| document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => { |
| document.getElementById('doc-email-more-menu').style.display = 'none'; |
| _saveDraft(); |
| }); |
| document.getElementById('doc-email-discard-btn')?.addEventListener('click', _discardEmail); |
| document.getElementById('doc-email-unread-btn')?.addEventListener('click', () => { |
| document.getElementById('doc-email-more-menu').style.display = 'none'; |
| _markUnreadAndClose(); |
| }); |
| document.getElementById('doc-email-schedule-btn')?.addEventListener('click', (e) => { |
| const anchor = document.getElementById('doc-email-send-caret') || e.currentTarget; |
| document.getElementById('doc-email-more-menu').style.display = 'none'; |
| _scheduleSend(anchor); |
| }); |
| document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply); |
|
|
| const collapseBtn = document.getElementById('doc-email-collapse-btn'); |
| if (collapseBtn && !collapseBtn._emailCollapseWired) { |
| collapseBtn._emailCollapseWired = true; |
| collapseBtn.addEventListener('pointerdown', (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| const focusState = _captureEmailBodyFocusState(); |
| const header = document.getElementById('doc-email-header'); |
| const nextCollapsed = !header?.classList.contains('doc-email-header-collapsed'); |
| _setEmailHeaderCollapsed(nextCollapsed); |
| if (!nextCollapsed) _restoreEmailBodyFocusState(focusState); |
| }); |
| collapseBtn.addEventListener('click', (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| } |
| ['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => { |
| document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary); |
| document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false })); |
| }); |
| document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader); |
| if (window.visualViewport && !window._docEmailViewportCollapseBound) { |
| window._docEmailViewportCollapseBound = true; |
| window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader); |
| } |
|
|
| |
| document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const menu = document.getElementById('doc-email-more-menu'); |
| const caret = document.getElementById('doc-email-send-caret'); |
| if (!menu) return; |
| const opening = menu.style.display === 'none'; |
| menu.style.display = opening ? '' : 'none'; |
| if (caret) caret.setAttribute('aria-expanded', String(opening)); |
| }); |
| document.addEventListener('click', (e) => { |
| const menu = document.getElementById('doc-email-more-menu'); |
| |
| |
| |
| if (menu && !e.target.closest('#doc-email-send-caret, #doc-email-more-menu')) { |
| menu.style.display = 'none'; |
| document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false'); |
| } |
| }); |
| document.addEventListener('keydown', (e) => { |
| if (e.key !== 'Escape') return; |
| const menu = document.getElementById('doc-email-more-menu'); |
| if (!menu || menu.style.display === 'none') return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| e.stopImmediatePropagation?.(); |
| menu.style.display = 'none'; |
| document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false'); |
| }, true); |
|
|
| |
| document.getElementById('doc-email-attach-btn')?.addEventListener('click', () => { |
| document.getElementById('doc-email-file-input')?.click(); |
| }); |
| document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => { |
| document.getElementById('doc-email-file-input')?.click(); |
| }); |
| document.getElementById('doc-email-file-input')?.addEventListener('change', _handleAttachUpload); |
|
|
| |
| document.getElementById('doc-email-show-cc')?.addEventListener('click', () => { |
| _setEmailHeaderCollapsed(false, { manual: false }); |
| const ccRow = document.getElementById('doc-email-cc-row'); |
| const bccRow = document.getElementById('doc-email-bcc-row'); |
| if (ccRow) ccRow.style.display = ''; |
| if (bccRow) bccRow.style.display = ''; |
| document.getElementById('doc-email-show-cc').style.display = 'none'; |
| _syncEmailHeaderSummary(); |
| }); |
|
|
| |
| |
| |
| _wireRecipientAutocomplete('doc-email-to', 'doc-email-to-suggestions'); |
| _wireRecipientAutocomplete('doc-email-cc', 'doc-email-cc-suggestions'); |
| _wireRecipientAutocomplete('doc-email-bcc', 'doc-email-bcc-suggestions'); |
|
|
| |
| document.getElementById('doc-header-preview-btn').addEventListener('click', () => { |
| const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); |
| if (lang === 'markdown') toggleMarkdownPreview(); |
| else if (lang === 'csv') toggleCsvPreview(); |
| else if (_isRenderLang(lang)) toggleHtmlPreview(); |
| else { |
| |
| const outputPanel = document.getElementById('doc-run-output'); |
| if (outputPanel && outputPanel.style.display !== 'none') { |
| outputPanel.style.display = 'none'; |
| } else { |
| runDocument(); |
| } |
| } |
| _syncHeaderActions(); |
| }); |
|
|
| |
| document.getElementById('doc-md-view-toggle')?.addEventListener('click', (e) => { |
| const opt = e.target.closest('.md-view-opt'); |
| if (!opt) return; |
| const wantPreview = opt.dataset.mdview === 'preview'; |
| const mdPrev = document.getElementById('doc-md-preview'); |
| const isPreview = mdPrev && mdPrev.style.display !== 'none'; |
| if (wantPreview !== isPreview) toggleMarkdownPreview(); |
| _syncHeaderActions(); |
| }); |
|
|
| |
| |
| |
| document.getElementById('doc-render-view-toggle')?.addEventListener('click', (e) => { |
| const opt = e.target.closest('.md-view-opt'); |
| if (!opt) return; |
| const wantRun = opt.dataset.renderview === 'run'; |
| const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); |
| if (lang === 'csv') { |
| const csv = document.getElementById('doc-csv-preview'); |
| const isOn = csv && csv.style.display !== 'none'; |
| if (wantRun !== isOn) toggleCsvPreview(); |
| } else if (_isRenderLang(lang)) { |
| const htmlPrev = document.getElementById('doc-html-preview'); |
| const isOn = htmlPrev && htmlPrev.style.display !== 'none'; |
| if (wantRun !== isOn) toggleHtmlPreview(); |
| } else { |
| |
| |
| if (wantRun) { |
| document.getElementById('doc-header-preview-btn')?.click(); |
| } else { |
| const out = document.getElementById('doc-run-output'); |
| if (out) out.style.display = 'none'; |
| } |
| } |
| _syncHeaderActions(); |
| }); |
|
|
| |
| const fontBtn = document.getElementById('doc-fontsize-btn'); |
| const editorWrap = document.getElementById('doc-editor-wrap'); |
| const _fontSizes = ['s', 'm', 'l']; |
| const _iconSizes = [12, 14, 16]; |
| let _fontIdx = parseInt(localStorage.getItem('odysseus-doc-fontsize') || '0', 10); |
| if (!(_fontIdx >= 0 && _fontIdx < 3)) _fontIdx = 0; |
| function _applyDocFont() { |
| const richEmailBody = document.getElementById('doc-email-richbody'); |
| [editorWrap, richEmailBody].filter(Boolean).forEach(el => { |
| el.classList.remove('doc-font-s', 'doc-font-m', 'doc-font-l'); |
| if (_fontSizes[_fontIdx] !== 's') el.classList.add('doc-font-' + _fontSizes[_fontIdx]); |
| }); |
| if (fontBtn) { |
| fontBtn.dataset.size = _fontSizes[_fontIdx]; |
| |
| const svg = fontBtn.querySelector('svg'); |
| if (svg) { const sz = _iconSizes[_fontIdx]; svg.setAttribute('width', sz); svg.setAttribute('height', sz); } |
| |
| fontBtn.querySelectorAll('.doc-fontsize-levels [data-sz]').forEach(el => { |
| const active = el.dataset.sz === _fontSizes[_fontIdx]; |
| el.classList.toggle('active', active); |
| el.style.display = active ? '' : 'none'; |
| }); |
| } |
| localStorage.setItem('odysseus-doc-fontsize', _fontIdx); |
| } |
| _applyDocFont(); |
| |
| if (fontBtn) fontBtn.addEventListener('click', () => { |
| _fontIdx = (_fontIdx + 1) % 3; |
| _applyDocFont(); |
| syncHighlighting(); |
| }); |
|
|
| |
| const docUndoBtn = document.getElementById('doc-undo-btn'); |
| if (docUndoBtn) docUndoBtn.addEventListener('click', async () => { |
| const pdfPane = document.getElementById('doc-pdf-view'); |
| const pdfVisible = pdfPane && pdfPane.style.display !== 'none'; |
| if (pdfVisible && await _undoPdfPaneAction()) return; |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) { |
| ta.focus(); |
| document.execCommand('undo'); |
| _dismissDocKb(); |
| } |
| }); |
|
|
| |
| const diffToggleBtn = document.getElementById('doc-diff-toggle-btn'); |
| if (diffToggleBtn) diffToggleBtn.addEventListener('click', async () => { |
| if (_diffModeActive) { |
| exitDiffMode(true); |
| return; |
| } |
| if (!activeDocId) return; |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (!ta) return; |
| const current = ta.value; |
|
|
| |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}/versions`); |
| if (!res.ok) throw new Error('Failed'); |
| const versions = await res.json(); |
| if (versions.length < 2) { |
| if (uiModule) uiModule.showToast('No previous version to compare'); |
| return; |
| } |
| |
| const prevContent = versions[1].content || ''; |
| if (prevContent === current) { |
| if (uiModule) uiModule.showToast('No changes from previous version'); |
| return; |
| } |
| enterDiffMode(prevContent, current); |
| } catch { |
| if (uiModule) uiModule.showError('Failed to load version history'); |
| } |
| }); |
|
|
| |
| document.getElementById('doc-export-pdf-btn')?.addEventListener('click', _downloadFilledPdf); |
|
|
| |
| |
| document.getElementById('doc-pdf-view-btn')?.addEventListener('click', () => { |
| const pane = document.getElementById('doc-pdf-view'); |
| const visible = pane && pane.style.display !== 'none'; |
| _setPdfViewActive(!visible); |
| }); |
|
|
| |
| |
| |
| document.getElementById('doc-pdf-add-text-btn')?.addEventListener('click', () => _setPdfDropMode(_pdfDropMode === 'text' ? null : 'text')); |
| document.getElementById('doc-pdf-add-check-btn')?.addEventListener('click', () => _setPdfDropMode(_pdfDropMode === 'check' ? null : 'check')); |
| document.getElementById('doc-pdf-add-sign-btn')?.addEventListener('click', () => _setPdfDropMode(_pdfDropMode === 'signature' ? null : 'signature')); |
| document.getElementById('doc-pdf-refresh-btn')?.addEventListener('click', () => _renderPdfPane()); |
|
|
| |
| initMdToolbar(); |
|
|
| |
| const ta = document.getElementById('doc-editor-textarea'); |
| const pre = document.getElementById('doc-editor-highlight'); |
| if (ta && pre) { |
| ta.addEventListener('input', () => { |
| |
| if (_selections.length) clearSelection(); |
| |
| |
| |
| if (!activeDocId && !_creatingDoc && ta.value.trim()) { |
| _autoCreateFromInput(ta.value); |
| return; |
| } |
| |
| const codeEl = document.getElementById('doc-editor-code'); |
| if (codeEl && !codeEl.dataset.hasDiff) { |
| codeEl.textContent = ta.value + '\n'; |
| codeEl.style.minHeight = ta.scrollHeight + 'px'; |
| } |
| if (pre) { |
| pre.scrollTop = ta.scrollTop; |
| pre.scrollLeft = ta.scrollLeft; |
| } |
| updateLineNumbers(ta.value); |
| |
| clearTimeout(_hlDebounce); |
| _hlDebounce = setTimeout(syncHighlighting, 80); |
| clearTimeout(_autoDetectDebounce); |
| _autoDetectDebounce = setTimeout(attemptAutoDetect, AUTO_DETECT_DELAY); |
| clearTimeout(_autoTitleDebounce); |
| _autoTitleDebounce = setTimeout(() => autoTitleFromContent(ta.value), 600); |
| clearTimeout(_autoSaveDebounce); |
| _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000); |
| }); |
| ta.addEventListener('scroll', () => { |
| const code = document.getElementById('doc-editor-code'); |
| if (code) code.style.minHeight = ta.scrollHeight + 'px'; |
| pre.scrollTop = ta.scrollTop; |
| pre.scrollLeft = ta.scrollLeft; |
| syncGutterScroll(); |
| syncSelectionOverlay(); |
| |
| if (_findMatches && _findMatches.length) { |
| const _q = document.getElementById('doc-find-input')?.value || ''; |
| if (_q) renderFindRects(_findMatches.map(s => [s, s + _q.length]), _findIdx); |
| } |
| }); |
| |
| ta.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape') { |
| if (_diffModeActive) { exitDiffMode(true); return; } |
| |
| |
| |
| |
| |
| |
| if (_selections.length > 0) { |
| clearSelection(); |
| e.preventDefault(); |
| e.stopPropagation(); |
| return; |
| } |
| |
| |
| e.preventDefault(); |
| e.stopPropagation(); |
| closePanel('down'); |
| return; |
| } |
| if (e.key === 'Tab') { |
| e.preventDefault(); |
| document.execCommand('insertText', false, '\t'); |
| } |
| |
| const lang = document.getElementById('doc-language-select')?.value; |
| if (lang === 'markdown' && (e.ctrlKey || e.metaKey)) { |
| if (e.key === 'b') { e.preventDefault(); applyMdFormat('bold'); } |
| else if (e.key === 'i') { e.preventDefault(); applyMdFormat('italic'); } |
| else if (e.key === 'k') { e.preventDefault(); applyMdFormat('link'); } |
| } |
| }); |
|
|
| |
| let _findMatches = []; |
| let _findIdx = -1; |
|
|
| function _openFindBar() { |
| const bar = document.getElementById('doc-find-bar'); |
| if (!bar) return; |
| bar.style.display = 'flex'; |
| |
| |
| |
| |
| |
| document.body.classList.add('doc-find-active'); |
| const inp = document.getElementById('doc-find-input'); |
| if (inp) { inp.focus(); inp.select(); } |
| } |
| function _closeFindBar() { |
| const bar = document.getElementById('doc-find-bar'); |
| if (bar) bar.style.display = 'none'; |
| document.body.classList.remove('doc-find-active'); |
| _findMatches = []; |
| _findIdx = -1; |
| const cnt = document.getElementById('doc-find-count'); |
| if (cnt) cnt.textContent = ''; |
| const codeEl = document.getElementById('doc-editor-code'); |
| if (codeEl) { |
| delete codeEl.dataset.findQuery; |
| delete codeEl.dataset.findCurrent; |
| applyFindMarks(codeEl); |
| } |
| renderFindRects([], -1); |
| ta.focus(); |
| } |
| function _doFind(dir, focusTextarea) { |
| const inp = document.getElementById('doc-find-input'); |
| const cnt = document.getElementById('doc-find-count'); |
| if (!inp) return; |
| const q = inp.value; |
| const codeEl = document.getElementById('doc-editor-code'); |
| if (!q) { |
| _findMatches = []; _findIdx = -1; |
| if (cnt) cnt.textContent = ''; |
| if (codeEl) { delete codeEl.dataset.findQuery; delete codeEl.dataset.findCurrent; applyFindMarks(codeEl); } |
| return; |
| } |
| const text = ta.value; |
| const lq = q.toLowerCase(); |
| const lt = text.toLowerCase(); |
| _findMatches = []; |
| let pos = 0; |
| while (true) { |
| const i = lt.indexOf(lq, pos); |
| if (i < 0) break; |
| _findMatches.push(i); |
| pos = i + 1; |
| } |
| if (_findMatches.length === 0) { |
| _findIdx = -1; |
| if (cnt) cnt.textContent = '0 results'; |
| if (codeEl) { codeEl.dataset.findQuery = q; delete codeEl.dataset.findCurrent; applyFindMarks(codeEl); } |
| renderFindRects([], -1); |
| return; |
| } |
| if (dir === 'next') { |
| _findIdx = _findIdx < _findMatches.length - 1 ? _findIdx + 1 : 0; |
| } else if (dir === 'prev') { |
| _findIdx = _findIdx > 0 ? _findIdx - 1 : _findMatches.length - 1; |
| } else { |
| _findIdx = 0; |
| } |
| if (cnt) cnt.textContent = `${_findIdx + 1} / ${_findMatches.length}`; |
| const matchPos = _findMatches[_findIdx]; |
| |
| ta.setSelectionRange(matchPos, matchPos + q.length); |
| const linesBefore = text.slice(0, matchPos).split('\n').length; |
| const lineH = parseFloat(getComputedStyle(ta).lineHeight) || 18; |
| ta.scrollTop = Math.max(0, (linesBefore - 3) * lineH); |
| if (codeEl) { |
| codeEl.dataset.findQuery = q; |
| codeEl.dataset.findCurrent = String(_findIdx); |
| applyFindMarks(codeEl); |
| } |
| |
| |
| renderFindRects(_findMatches.map(s => [s, s + q.length]), _findIdx); |
| if (focusTextarea) ta.focus(); |
| } |
|
|
| document.getElementById('doc-find-close')?.addEventListener('click', _closeFindBar); |
| document.getElementById('doc-find-next')?.addEventListener('click', () => _doFind('next', true)); |
| document.getElementById('doc-find-prev')?.addEventListener('click', () => _doFind('prev', true)); |
| document.getElementById('doc-find-input')?.addEventListener('input', () => _doFind('first', false)); |
| document.getElementById('doc-find-input')?.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape') { e.preventDefault(); _closeFindBar(); } |
| else if (e.key === 'Enter') { e.preventDefault(); _doFind(e.shiftKey ? 'prev' : 'next', false); } |
| }); |
|
|
| |
| pane.addEventListener('keydown', (e) => { |
| if ((e.ctrlKey || e.metaKey) && e.key === 'f') { |
| e.preventDefault(); |
| e.stopPropagation(); |
| _openFindBar(); |
| } |
| }); |
|
|
| |
| |
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key !== 'Delete' && e.key !== 'Backspace') return; |
| if (!isPanelOpen()) return; |
| const t = e.target; |
| if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; |
| e.preventDefault(); |
| deleteActiveDocument(); |
| }); |
|
|
| |
| |
| |
| |
| let _dragDepth = 0; |
| const _isEmailDrag = (e) => { |
| const doc = docs.get(activeDocId); |
| if (!doc || doc.language !== 'email') return false; |
| const dt = e.dataTransfer; |
| if (!dt) return false; |
| |
| return dt.types && Array.from(dt.types).includes('Files'); |
| }; |
| pane.addEventListener('dragenter', (e) => { |
| if (!_isEmailDrag(e)) return; |
| e.preventDefault(); |
| _dragDepth++; |
| pane.classList.add('email-dragover'); |
| }); |
| pane.addEventListener('dragover', (e) => { |
| if (!_isEmailDrag(e)) return; |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = 'copy'; |
| }); |
| pane.addEventListener('dragleave', (e) => { |
| if (!_isEmailDrag(e)) return; |
| _dragDepth = Math.max(0, _dragDepth - 1); |
| if (_dragDepth === 0) pane.classList.remove('email-dragover'); |
| }); |
| pane.addEventListener('drop', async (e) => { |
| if (!_isEmailDrag(e)) return; |
| e.preventDefault(); |
| _dragDepth = 0; |
| pane.classList.remove('email-dragover'); |
| const files = e.dataTransfer.files; |
| if (files && files.length) await _uploadComposeFiles(files); |
| }); |
|
|
| |
| ta.addEventListener('mouseup', () => { |
| setTimeout(updateSelectionState, 50); |
| }); |
| ta.addEventListener('keyup', (e) => { |
| if (e.shiftKey) updateSelectionState(); |
| }); |
| |
| |
| ta.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape' && _selections.length > 0) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| clearSelection(); |
| } |
| }); |
| } |
|
|
| renderTabs(); |
|
|
| |
| if (docs.size === 0 || !activeDocId) { |
| showEmptyState(); |
| } |
| } |
|
|
| |
| let _lastMdFormat = { action: null, t: 0 }; |
| |
| |
| |
| function _promptLink(defaultText = '') { |
| return new Promise(resolve => { |
| const overlay = document.createElement('div'); |
| overlay.id = 'doc-link-prompt-overlay'; |
| overlay.className = 'modal'; |
| overlay.innerHTML = |
| '<div class="modal-content styled-confirm-box styled-prompt-box">' + |
| '<div class="modal-header"><h4>Insert link</h4></div>' + |
| '<div class="modal-body">' + |
| '<input type="text" id="doc-link-text" class="styled-prompt-input" placeholder="Link text (optional)" maxlength="500" />' + |
| '<input type="url" id="doc-link-url" class="styled-prompt-input" placeholder="https://example.com" maxlength="2048" style="margin-top:8px;" />' + |
| '</div>' + |
| '<div class="modal-footer">' + |
| '<button id="doc-link-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button>' + |
| '<button id="doc-link-ok" class="confirm-btn confirm-btn-primary">Insert</button>' + |
| '</div>' + |
| '</div>'; |
| document.body.appendChild(overlay); |
| const textEl = overlay.querySelector('#doc-link-text'); |
| const urlEl = overlay.querySelector('#doc-link-url'); |
| textEl.value = defaultText || ''; |
| function done(result) { |
| overlay.remove(); |
| document.removeEventListener('keydown', onKey, true); |
| resolve(result); |
| } |
| function submit() { |
| const url = (urlEl.value || '').trim(); |
| if (!url) { urlEl.focus(); return; } |
| done({ url, text: (textEl.value || '').trim() }); |
| } |
| function onKey(e) { |
| if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(null); } |
| } |
| overlay.querySelector('#doc-link-ok').addEventListener('click', submit); |
| overlay.querySelector('#doc-link-cancel').addEventListener('click', () => done(null)); |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); |
| urlEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); |
| textEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); urlEl.focus(); } }); |
| document.addEventListener('keydown', onKey, true); |
| |
| requestAnimationFrame(() => { (defaultText ? urlEl : textEl).focus(); }); |
| }); |
| } |
|
|
| |
| |
| |
| async function _wysiwygInsertLink(rich) { |
| const selObj = window.getSelection(); |
| let savedRange = null; |
| if (selObj && selObj.rangeCount) { |
| const r = selObj.getRangeAt(0); |
| if (rich.contains(r.commonAncestorContainer)) savedRange = r.cloneRange(); |
| } |
| const selText = savedRange ? savedRange.toString() : ''; |
| let res; |
| try { res = await _promptLink(selText); } catch (_) { res = null; } |
| if (!res) { rich.focus(); return; } |
| let url = (res.url || '').trim(); |
| if (!url) { rich.focus(); return; } |
| if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//')) url = 'https://' + url; |
| const linkText = (res.text || '').trim() || selText || url; |
|
|
| if (!savedRange) { |
| savedRange = document.createRange(); |
| savedRange.selectNodeContents(rich); |
| savedRange.collapse(false); |
| } |
| const a = document.createElement('a'); |
| a.href = url; |
| if (selText && linkText === selText) { |
| |
| a.appendChild(savedRange.extractContents()); |
| } else { |
| savedRange.deleteContents(); |
| a.textContent = linkText; |
| } |
| savedRange.insertNode(a); |
| |
| const after = document.createRange(); |
| after.setStartAfter(a); |
| after.collapse(true); |
| rich.focus(); |
| const s = window.getSelection(); |
| s.removeAllRanges(); |
| s.addRange(after); |
| _syncEmailRichbody(rich); |
| } |
|
|
| function applyMdFormat(action) { |
| |
| |
| |
| const _now = Date.now(); |
| if (_lastMdFormat.action === action && _now - _lastMdFormat.t < 350) return; |
| _lastMdFormat = { action, t: _now }; |
| |
| |
| const _rich = _emailRichbodyActive(); |
| if (_rich) { |
| _rich.focus(); |
| |
| |
| if (action === 'link') { _wysiwygInsertLink(_rich); return; } |
| const _cmd = { bold: 'bold', italic: 'italic', strike: 'strikeThrough', |
| ul: 'insertUnorderedList', ol: 'insertOrderedList', hr: 'insertHorizontalRule' }; |
| try { |
| if (_cmd[action]) document.execCommand(_cmd[action]); |
| else if (action === 'h1' || action === 'h2' || action === 'h3') { |
| |
| |
| const cur = _currentBlockTag(_rich); |
| document.execCommand('formatBlock', false, (cur === action) ? 'div' : action); |
| } else if (action === 'code') { |
| const cur = _currentBlockTag(_rich); |
| document.execCommand('formatBlock', false, (cur === 'pre') ? 'div' : 'pre'); |
| } |
| |
| } catch (_) {} |
| _syncEmailRichbody(_rich); |
| if (_rich._syncActive) _rich._syncActive(); |
| return; |
| } |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (!ta) return; |
| const start = ta.selectionStart; |
| const end = ta.selectionEnd; |
| const val = ta.value; |
| const sel = val.substring(start, end); |
| const before = val.substring(0, start); |
| const after = val.substring(end); |
|
|
| |
| const wrapMarks = { bold: '**', italic: '*', strike: '~~', code: '`' }; |
| if (wrapMarks[action]) { |
| const m = wrapMarks[action]; |
| _applyWrapToggle(ta, before, sel, after, start, end, m, action); |
| return; |
| } |
|
|
| |
| if (action === 'ol') { |
| _applyOrderedList(ta, start, end); |
| return; |
| } |
|
|
| |
| |
| if (action === 'h1' || action === 'h2' || action === 'h3') { |
| _applyHeadingToggle(ta, start, { h1: '# ', h2: '## ', h3: '### ' }[action]); |
| return; |
| } |
|
|
| |
| const prefixMap = { quote: '> ', ul: '- ', check: '- [ ] ' }; |
| if (prefixMap[action]) { |
| _applyLinePrefixToggle(ta, start, end, prefixMap[action]); |
| return; |
| } |
|
|
| |
| let insert = ''; |
| let sS = start, sE = start; |
| switch (action) { |
| case 'link': |
| if (sel) { |
| insert = `[${sel}](url)`; |
| sS = start + 1; sE = start + 1 + sel.length; |
| } else { |
| insert = '[text](url)'; |
| sS = start + 1; sE = start + 5; |
| } |
| break; |
| case 'codeblock': { |
| |
| const linesBefore = val.substring(0, start).split('\n'); |
| const linesAfter = val.substring(end).split('\n'); |
| |
| let openIdx = -1; |
| for (let i = linesBefore.length - 1; i >= 0; i--) { |
| if (/^```/.test(linesBefore[i].trimEnd())) { openIdx = i; break; } |
| } |
| |
| let closeIdx = -1; |
| for (let i = 0; i < linesAfter.length; i++) { |
| if (/^```\s*$/.test(linesAfter[i].trimEnd())) { closeIdx = i; break; } |
| } |
| if (openIdx >= 0 && closeIdx >= 0) { |
| |
| const openLineStart = linesBefore.slice(0, openIdx).join('\n').length + (openIdx > 0 ? 1 : 0); |
| const openLineEnd = openLineStart + linesBefore[openIdx].length + 1; |
| const closeLineStart = end + linesAfter.slice(0, closeIdx).join('\n').length + (closeIdx > 0 ? 1 : 0); |
| const closeLineEnd = closeLineStart + linesAfter[closeIdx].length + (closeIdx < linesAfter.length - 1 ? 1 : 0); |
| |
| _replaceRange(ta, closeLineStart, closeLineEnd, ''); |
| _replaceRange(ta, openLineStart, openLineEnd, ''); |
| const inner = val.substring(openLineEnd, closeLineStart); |
| ta.selectionStart = openLineStart; |
| ta.selectionEnd = openLineStart + inner.length; |
| return; |
| } |
| |
| const nl = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; |
| insert = nl + '```\n' + (sel || '') + '\n```\n'; |
| sS = start + nl.length + 4; |
| sE = sS + (sel ? sel.length : 0); |
| break; |
| } |
| case 'hr': { |
| const nl = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; |
| insert = `${nl}---\n`; |
| sE = sS = start + insert.length; |
| break; |
| } |
| default: return; |
| } |
| _replaceRange(ta, start, end, insert); |
| ta.selectionStart = sS; |
| ta.selectionEnd = sE; |
| } |
|
|
| |
| function _replaceRange(ta, from, to, text) { |
| ta.focus(); |
| ta.selectionStart = from; |
| ta.selectionEnd = to; |
| const before = ta.value; |
| let ok = false; |
| try { ok = document.execCommand('insertText', false, text); } catch (_) { ok = false; } |
| |
| |
| |
| |
| |
| if (!ok && ta.value === before) { |
| ta.value = before.slice(0, from) + text + before.slice(to); |
| ta.selectionStart = ta.selectionEnd = from + text.length; |
| ta.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| } |
|
|
| |
| function _applyWrapToggle(ta, before, sel, after, start, end, mark, action) { |
| const mLen = mark.length; |
|
|
| |
| if (sel.startsWith(mark) && sel.endsWith(mark) && sel.length > mLen * 2) { |
| const inner = sel.slice(mLen, -mLen); |
| _replaceRange(ta, start, end, inner); |
| ta.selectionStart = start; |
| ta.selectionEnd = start + inner.length; |
| return; |
| } |
|
|
| |
| if (before.endsWith(mark) && after.startsWith(mark)) { |
| _replaceRange(ta, start - mLen, end + mLen, sel); |
| ta.selectionStart = start - mLen; |
| ta.selectionEnd = end - mLen; |
| return; |
| } |
|
|
| |
| |
| const inner = sel; |
| const wrapped = mark + inner + mark; |
| _replaceRange(ta, start, end, wrapped); |
| ta.selectionStart = start + mLen; |
| ta.selectionEnd = start + mLen + inner.length; |
| } |
|
|
| |
| |
| |
| |
| function _currentBlockTag(root) { |
| const sel = window.getSelection(); |
| if (!sel || !sel.rangeCount) return ''; |
| let node = sel.getRangeAt(0).startContainer; |
| if (node.nodeType === 3) node = node.parentNode; |
| while (node && node !== root) { |
| const tag = node.tagName && node.tagName.toLowerCase(); |
| if (tag && /^(h1|h2|h3|h4|h5|h6|p|div|pre|blockquote|li)$/.test(tag)) return tag; |
| node = node.parentNode; |
| } |
| return ''; |
| } |
|
|
| |
| |
| |
| function _applyHeadingToggle(ta, caret, prefix) { |
| const val = ta.value; |
| const lineStart = val.lastIndexOf('\n', caret - 1) + 1; |
| const nlIdx = val.indexOf('\n', caret); |
| const lineEnd = nlIdx === -1 ? val.length : nlIdx; |
| const line = val.substring(lineStart, lineEnd); |
| const m = line.match(/^(#{1,6}) /); |
| let newLine; |
| if (m && m[1].length === prefix.trim().length) { |
| newLine = line.slice(m[0].length); |
| } else if (m) { |
| newLine = prefix + line.slice(m[0].length); |
| } else { |
| newLine = prefix + line; |
| } |
| _replaceRange(ta, lineStart, lineEnd, newLine); |
| const delta = newLine.length - line.length; |
| const pos = Math.max(lineStart, caret + delta); |
| ta.selectionStart = ta.selectionEnd = pos; |
| ta.focus(); |
| } |
|
|
| function _applyLinePrefixToggle(ta, start, end, prefix) { |
| const val = ta.value; |
| const sel = val.substring(start, end); |
| const lineStart = val.lastIndexOf('\n', start - 1) + 1; |
|
|
| if (sel) { |
| |
| const lines = sel.split('\n'); |
| const nonEmpty = lines.filter(l => l.trim()); |
| const allPrefixed = nonEmpty.length > 0 && nonEmpty.every(l => l.startsWith(prefix)); |
| const result = allPrefixed |
| ? lines.map(l => l.startsWith(prefix) ? l.slice(prefix.length) : l).join('\n') |
| : lines.map(l => l.trim() ? prefix + l : l).join('\n'); |
| _replaceRange(ta, start, end, result); |
| ta.selectionStart = start; |
| ta.selectionEnd = start + result.length; |
| } else { |
| |
| const lineBefore = val.substring(lineStart, start); |
|
|
| if (lineBefore.startsWith(prefix)) { |
| |
| _replaceRange(ta, lineStart, lineStart + prefix.length, ''); |
| } else { |
| |
| _replaceRange(ta, lineStart, lineStart, prefix); |
| } |
| } |
| } |
|
|
| |
| function _applyOrderedList(ta, start, end) { |
| const val = ta.value; |
| const sel = val.substring(start, end); |
| const lineStart = val.lastIndexOf('\n', start - 1) + 1; |
|
|
| if (sel) { |
| const lines = sel.split('\n'); |
| const nonEmpty = lines.filter(l => l.trim()); |
| const allNumbered = nonEmpty.length > 0 && nonEmpty.every(l => /^\d+\.\s/.test(l)); |
| const result = allNumbered |
| ? lines.map(l => l.replace(/^\d+\.\s/, '')).join('\n') |
| : (() => { let n = 0; return lines.map(l => l.trim() ? `${++n}. ${l}` : l).join('\n'); })(); |
| _replaceRange(ta, start, end, result); |
| ta.selectionStart = start; |
| ta.selectionEnd = start + result.length; |
| } else { |
| const lineBefore = val.substring(lineStart, start); |
| if (/^\d+\.\s/.test(lineBefore)) { |
| const prefixLen = lineBefore.match(/^\d+\.\s/)[0].length; |
| _replaceRange(ta, lineStart, lineStart + prefixLen, ''); |
| } else { |
| |
| const prevText = val.substring(0, lineStart); |
| const prevMatch = prevText.match(/(\d+)\.\s[^\n]*\n$/); |
| const num = prevMatch ? parseInt(prevMatch[1]) + 1 : 1; |
| _replaceRange(ta, lineStart, lineStart, `${num}. `); |
| } |
| } |
| } |
|
|
| |
| |
| |
| let _mdDdOpenedAt = 0; |
| function _showMdDropdown(toggleBtn) { |
| const kind = toggleBtn.dataset.dd; |
| const now = Date.now(); |
| const existing = document.getElementById('doc-md-dd-menu'); |
| |
| |
| |
| if (existing && existing.dataset.dd === kind && (now - _mdDdOpenedAt) < 400) return; |
| const prevKind = existing && existing.dataset.dd; |
| if (existing) existing.remove(); |
| if (existing && prevKind === kind) return; |
| _mdDdOpenedAt = now; |
|
|
| const groups = { |
| heading: [['h1', 'Heading 1', 'H1'], ['h2', 'Heading 2', 'H2'], ['h3', 'Heading 3', 'H3']], |
| code: [['code', 'Inline code', '`'], ['codeblock', 'Code block', '```']], |
| list: [['ul', 'Bullet list', '•'], ['ol', 'Numbered list', '1.']], |
| }; |
| const items = groups[kind]; |
| if (!items) return; |
|
|
| const rect = toggleBtn.getBoundingClientRect(); |
| const menu = document.createElement('div'); |
| menu.id = 'doc-md-dd-menu'; |
| menu.dataset.dd = kind; |
| menu.className = 'doc-overflow-menu open'; |
| menu.style.position = 'fixed'; |
| menu.style.top = (rect.bottom + 4) + 'px'; |
| menu.style.left = rect.left + 'px'; |
| menu.style.zIndex = '9999'; |
| items.forEach(([md, label, ico]) => { |
| const it = document.createElement('button'); |
| it.className = 'doc-overflow-item'; |
| const icoSpan = document.createElement('span'); |
| icoSpan.className = 'md-dd-ico'; |
| icoSpan.textContent = ico; |
| const lbl = document.createElement('span'); |
| lbl.textContent = label; |
| it.append(icoSpan, lbl); |
| |
| it.addEventListener('mousedown', (ev) => ev.preventDefault()); |
| it.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); applyMdFormat(md); }); |
| menu.appendChild(it); |
| }); |
| document.body.appendChild(menu); |
|
|
| const close = (ev) => { |
| if (ev && ev.type === 'keydown') { |
| if (ev.key !== 'Escape') return; |
| ev.preventDefault(); |
| ev.stopPropagation(); |
| ev.stopImmediatePropagation?.(); |
| } |
| if (ev && ev.type === 'click') { |
| |
| if (Date.now() - _mdDdOpenedAt < 400) return; |
| if (menu.contains(ev.target) || toggleBtn.contains(ev.target)) return; |
| } |
| menu.remove(); |
| document.removeEventListener('click', close, true); |
| document.removeEventListener('keydown', close, true); |
| window.removeEventListener('scroll', close, true); |
| window.removeEventListener('resize', close, true); |
| }; |
| setTimeout(() => { |
| document.addEventListener('click', close, true); |
| document.addEventListener('keydown', close, true); |
| window.addEventListener('scroll', close, true); |
| window.addEventListener('resize', close, true); |
| }, 0); |
| } |
|
|
| function initMdToolbar() { |
| const toolbar = document.getElementById('doc-md-toolbar'); |
| if (!toolbar) return; |
|
|
| const itemsWrap = document.getElementById('md-toolbar-items'); |
| const overflowWrapper = document.getElementById('md-toolbar-overflow-wrapper'); |
| const overflowToggle = document.getElementById('md-toolbar-overflow-toggle'); |
| const overflowMenu = document.getElementById('md-toolbar-overflow-menu'); |
| const undoBtn = document.getElementById('md-toolbar-undo'); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| toolbar.addEventListener('mousedown', (e) => { |
| if (e.target.closest('[data-md], .md-dd-toggle, .emoji-picker-btn')) e.preventDefault(); |
| }); |
|
|
| toolbar.addEventListener('click', (e) => { |
| const dd = e.target.closest('.md-dd-toggle'); |
| if (dd) { e.preventDefault(); _showMdDropdown(dd); return; } |
| const btn = e.target.closest('[data-md]'); |
| if (!btn) return; |
| e.preventDefault(); |
| applyMdFormat(btn.dataset.md); |
| }); |
|
|
| |
| if (undoBtn) { |
| undoBtn.addEventListener('click', (e) => { |
| e.preventDefault(); |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) { ta.focus(); document.execCommand('undo'); } |
| }); |
| } |
|
|
| |
| let _mdMenuOpen = false; |
| |
| |
| const scrollLeftBtn = document.getElementById('md-scroll-left'); |
| const scrollRightBtn = document.getElementById('md-scroll-right'); |
| function updateScrollArrows() { |
| if (!itemsWrap || !scrollLeftBtn || !scrollRightBtn) return; |
| const maxScroll = itemsWrap.scrollWidth - itemsWrap.clientWidth; |
| const overflowing = maxScroll > 2; |
| scrollLeftBtn.style.display = (overflowing && itemsWrap.scrollLeft > 1) ? 'flex' : 'none'; |
| scrollRightBtn.style.display = (overflowing && itemsWrap.scrollLeft < maxScroll - 1) ? 'flex' : 'none'; |
| } |
| scrollLeftBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: 0, behavior: 'smooth' })); |
| scrollRightBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: itemsWrap.scrollWidth, behavior: 'smooth' })); |
| itemsWrap?.addEventListener('scroll', updateScrollArrows, { passive: true }); |
| if (window.ResizeObserver && itemsWrap) { |
| new ResizeObserver(updateScrollArrows).observe(itemsWrap); |
| } |
|
|
| function syncMdOverflow() { |
| if (overflowWrapper) overflowWrapper.style.display = 'none'; |
| updateScrollArrows(); |
| } |
|
|
| function closeMdMenu() { |
| _mdMenuOpen = false; |
| if (overflowMenu) overflowMenu.classList.remove('open'); |
| } |
|
|
| if (overflowToggle) { |
| overflowToggle.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| _mdMenuOpen = !_mdMenuOpen; |
| if (_mdMenuOpen) { |
| document.body.appendChild(overflowMenu); |
| const rect = overflowToggle.getBoundingClientRect(); |
| overflowMenu.style.position = 'fixed'; |
| overflowMenu.style.top = (rect.bottom + 2) + 'px'; |
| overflowMenu.style.right = (window.innerWidth - rect.right) + 'px'; |
| overflowMenu.style.left = 'auto'; |
| } else { |
| overflowWrapper.appendChild(overflowMenu); |
| } |
| overflowMenu.classList.toggle('open', _mdMenuOpen); |
| }); |
| } |
| document.addEventListener('click', () => { |
| if (_mdMenuOpen) { closeMdMenu(); overflowWrapper.appendChild(overflowMenu); } |
| }); |
|
|
| |
| let _mdResizeTimer; |
| window.addEventListener('resize', () => { |
| clearTimeout(_mdResizeTimer); |
| _mdResizeTimer = setTimeout(syncMdOverflow, 100); |
| }); |
|
|
| |
| const lang = document.getElementById('doc-language-select')?.value; |
| if (lang === 'markdown') toolbar.style.display = ''; |
|
|
| |
| requestAnimationFrame(syncMdOverflow); |
| |
| toolbar._syncOverflow = syncMdOverflow; |
| } |
|
|
| |
| const _DOC_RECENTS_KEY = 'odysseus-doc-actions-recent'; |
| const _DOC_MAX_VISIBLE = 2; |
|
|
| function _getDocRecent() { |
| try { return JSON.parse(localStorage.getItem(_DOC_RECENTS_KEY) || '[]'); } catch { return []; } |
| } |
| function _trackDocAction(id) { |
| let recent = _getDocRecent().filter(x => x !== id); |
| recent.unshift(id); |
| if (recent.length > 10) recent.length = 10; |
| localStorage.setItem(_DOC_RECENTS_KEY, JSON.stringify(recent)); |
| } |
|
|
| function initActionOverflow() { |
| const actionsEl = document.getElementById('doc-editor-actions'); |
| const wrapper = document.getElementById('doc-overflow-wrapper'); |
| const toggle = document.getElementById('doc-overflow-toggle'); |
| const menu = document.getElementById('doc-overflow-menu'); |
| if (!actionsEl || !wrapper || !toggle || !menu) return; |
|
|
| const allBtns = Array.from(actionsEl.querySelectorAll('.doc-collapsible-btn')); |
| let _menuOpen = false; |
|
|
| function syncOverflow() { |
| allBtns.forEach(b => { b.classList.remove('doc-collapsed'); }); |
| menu.innerHTML = ''; |
|
|
| |
| const available = allBtns.filter(b => b.style.display !== 'none'); |
|
|
| |
| const recent = _getDocRecent(); |
| const defaults = ['doc-copy-btn', 'doc-export-btn', 'doc-save-btn']; |
| const order = recent.length > 0 ? recent : defaults; |
|
|
| |
| const lang = document.getElementById('doc-language-select')?.value; |
| const pinned = []; |
| if (lang === 'markdown') { |
| const mdBtn = available.find(b => b.id === 'doc-md-btn'); |
| if (mdBtn) pinned.push(mdBtn); |
| } |
|
|
| const sorted = [...available].sort((a, b) => { |
| const ai = order.indexOf(a.id), bi = order.indexOf(b.id); |
| if (ai >= 0 && bi >= 0) return ai - bi; |
| if (ai >= 0) return -1; |
| if (bi >= 0) return 1; |
| return 0; |
| }); |
|
|
| |
| const visible = [...pinned]; |
| for (const btn of sorted) { |
| if (visible.length >= _DOC_MAX_VISIBLE) break; |
| if (!visible.includes(btn)) visible.push(btn); |
| } |
| |
| while (visible.length > _DOC_MAX_VISIBLE) visible.pop(); |
| const overflow = sorted.filter(b => !visible.includes(b)); |
|
|
| |
| overflow.forEach(b => b.classList.add('doc-collapsed')); |
|
|
| |
| for (const btn of visible) { |
| actionsEl.insertBefore(btn, wrapper); |
| } |
|
|
| if (overflow.length > 0) { |
| wrapper.style.display = ''; |
| overflow.forEach(btn => { |
| const item = document.createElement('button'); |
| item.className = 'doc-overflow-item'; |
| item.innerHTML = btn.innerHTML + '<span>' + (btn.title || '') + '</span>'; |
| item.addEventListener('click', (e) => { |
| _trackDocAction(btn.id); |
| |
| if (btn.id === 'doc-export-btn') { |
| e.stopPropagation(); |
| const savedRect = item.getBoundingClientRect(); |
| closeMenu(); |
| setTimeout(() => showExportMenu(null, savedRect), 50); |
| return; |
| } |
| closeMenu(); |
| btn.click(); |
| syncOverflow(); |
| }); |
| menu.appendChild(item); |
| }); |
| } else { |
| wrapper.style.display = 'none'; |
| } |
| } |
|
|
| function closeMenu() { |
| _menuOpen = false; |
| menu.classList.remove('open'); |
| } |
|
|
| toggle.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| _menuOpen = !_menuOpen; |
| if (_menuOpen) { |
| |
| document.body.appendChild(menu); |
| const rect = toggle.getBoundingClientRect(); |
| menu.style.position = 'fixed'; |
| menu.style.top = (rect.bottom + 2) + 'px'; |
| menu.style.right = (window.innerWidth - rect.right) + 'px'; |
| menu.style.left = 'auto'; |
| } else { |
| wrapper.appendChild(menu); |
| } |
| menu.classList.toggle('open', _menuOpen); |
| }); |
| document.addEventListener('click', () => { |
| if (_menuOpen) { closeMenu(); wrapper.appendChild(menu); } |
| }); |
|
|
| |
| allBtns.forEach(btn => { |
| btn.addEventListener('click', () => { |
| _trackDocAction(btn.id); |
| |
| setTimeout(syncOverflow, 100); |
| }); |
| }); |
|
|
| requestAnimationFrame(syncOverflow); |
| _syncOverflow = syncOverflow; |
| } |
|
|
| |
| function initDividerDrag(divider, pane, isRight) { |
| let dragging = false; |
| divider.addEventListener('mousedown', (e) => { |
| dragging = true; |
| document.body.style.cursor = 'col-resize'; |
| document.body.style.userSelect = 'none'; |
| e.preventDefault(); |
| }); |
| document.addEventListener('mousemove', (e) => { |
| if (!dragging) return; |
| const width = isRight |
| ? e.clientX |
| : window.innerWidth - e.clientX; |
| pane.style.width = Math.max(250, Math.min(width, window.innerWidth * 0.7)) + 'px'; |
| pane.style.flex = 'none'; |
| }); |
| document.addEventListener('mouseup', () => { |
| if (dragging) { |
| dragging = false; |
| document.body.style.cursor = ''; |
| document.body.style.userSelect = ''; |
| |
| syncHighlighting(); |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) updateLineNumbers(ta.value); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| let _minimizedDocId = null; |
|
|
| function _ensureDocChipRegistered() { |
| if (Modals.isRegistered('doc-panel')) return; |
| Modals.register('doc-panel', { |
| |
| |
| closeFn: () => { |
| |
| |
| const id = _minimizedDocId; |
| _minimizedDocId = null; |
| if (id) _detachDocFromSession(id); |
| }, |
| restoreFn: () => { |
| const id = _minimizedDocId; |
| _minimizedDocId = null; |
| |
| |
| |
| openPanel(); |
| if (id && docs.has(id)) { |
| try { switchToDoc(id); } catch (e) { console.error('Restore doc failed:', e); } |
| } |
| }, |
| }); |
| } |
|
|
| export function closePanel(direction) { |
| if (!isOpen) { |
| if (direction !== 'down' && Modals.isRegistered('doc-panel')) { |
| _minimizedDocId = null; |
| _markDocVisibleState(_lastSessionId, 'closed'); |
| Modals.unregister('doc-panel'); |
| } |
| return; |
| } |
| isOpen = false; |
| |
| |
| |
| |
| if (direction !== 'down' && (('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0)) { |
| const _dropKb = () => { |
| const ae = document.activeElement; |
| if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) { try { ae.blur(); } catch (_) {} } |
| }; |
| _dropKb(); |
| requestAnimationFrame(_dropKb); |
| setTimeout(_dropKb, 80); |
| } |
| |
| saveCurrentToMap(); |
|
|
| |
| |
| |
| |
| if (direction === 'down') { |
| _minimizedDocId = activeDocId; |
| _markDocVisibleState(_lastSessionId, 'minimized'); |
| _ensureDocChipRegistered(); |
| Modals.minimize('doc-panel'); |
| } else if (Modals.isRegistered('doc-panel')) { |
| _minimizedDocId = null; |
| _markDocVisibleState(_lastSessionId, 'closed'); |
| Modals.unregister('doc-panel'); |
| } else { |
| _markDocVisibleState(_lastSessionId, 'closed'); |
| } |
|
|
| const pane = document.getElementById('doc-editor-pane'); |
| const divider = document.getElementById('doc-divider'); |
|
|
| const _finishClose = () => { |
| |
| |
| |
| |
| |
| if (isOpen) { if (pane) pane.remove(); if (divider) divider.remove(); return; } |
| document.body.classList.remove('doc-view'); |
| const container = document.getElementById('chat-container'); |
| if (container) container.style.display = ''; |
| if (pane) pane.remove(); |
| if (divider) divider.remove(); |
| activeDocId = null; |
| const btn = document.getElementById('overflow-doc-btn'); |
| if (btn) btn.classList.remove('active'); |
| const docInd = document.getElementById('doc-indicator-btn'); |
| if (docInd) docInd.classList.remove('active'); |
| }; |
|
|
| if (pane) { |
| |
| let transform; |
| if (direction === 'down') { |
| |
| transform = window.innerWidth <= 768 ? 'translateY(100%)' : 'translateY(30px)'; |
| } else { |
| const fromLeft = pane.classList.contains('doc-left'); |
| transform = fromLeft ? 'translateX(-40px)' : 'translateX(40px)'; |
| } |
| pane.style.transition = 'transform 0.15s ease-in, opacity 0.1s ease-in'; |
| pane.style.transform = transform; |
| pane.style.opacity = '0'; |
| if (divider) { divider.style.transition = 'opacity 0.1s ease-in'; divider.style.opacity = '0'; } |
| pane.addEventListener('transitionend', _finishClose, { once: true }); |
| |
| setTimeout(_finishClose, 200); |
| } else { |
| _finishClose(); |
| } |
| } |
|
|
| |
| export function swapSide() { |
| if (!isOpen) return; |
| const pane = document.getElementById('doc-editor-pane'); |
| const divider = document.getElementById('doc-divider'); |
| const container = document.getElementById('chat-container'); |
| if (!pane || !divider || !container) return; |
|
|
| const sidebar = document.getElementById('sidebar'); |
| const isRight = sidebar && sidebar.classList.contains('right-side'); |
|
|
| if (isRight) { |
| |
| pane.classList.add('doc-left'); |
| container.parentNode.insertBefore(pane, container); |
| container.parentNode.insertBefore(divider, container); |
| } else { |
| |
| pane.classList.remove('doc-left'); |
| container.after(divider); |
| divider.after(pane); |
| } |
|
|
| |
| initDividerDrag(divider, pane, isRight); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| export async function newDocument() { |
| let sessionId = docs.get(activeDocId)?.sessionId |
| || _lastSessionId |
| || (sessionModule && sessionModule.getCurrentSessionId()); |
| if (!sessionId) { |
| try { sessionId = await _autoCreateSession(); } |
| catch (e) { console.error('Failed to auto-create session for document:', e); return; } |
| } |
| await createDocument(sessionId); |
| } |
|
|
| export async function createDocument(sessionId) { |
| if (_creatingDoc) return; |
| _creatingDoc = true; |
| |
| |
| |
| const wasEmpty = !activeDocId; |
| try { |
| const res = await fetch(`${API_BASE}/api/document`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| session_id: sessionId, |
| title: '', |
| content: '', |
| language: 'markdown', |
| }), |
| }); |
| const doc = await res.json(); |
| addDocToTabs(doc, sessionId); |
| if (!isOpen) openPanel(); |
| |
| let textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) { |
| textarea.disabled = false; |
| textarea.placeholder = 'Document content...'; |
| } |
| |
| |
| const typed = (wasEmpty && textarea && textarea.value.trim()) ? textarea.value : ''; |
| switchToDoc(doc.id); |
| if (typed) { |
| textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) textarea.value = typed; |
| const d = docs.get(doc.id); |
| if (d) d.content = typed; |
| syncHighlighting(); |
| clearTimeout(_autoSaveDebounce); |
| _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800); |
| } |
| textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) textarea.focus(); |
| } catch (e) { |
| console.error('Failed to create document:', e); |
| if (uiModule) uiModule.showError('Failed to create document'); |
| } finally { |
| _creatingDoc = false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function injectFreshDoc(doc) { |
| if (!doc || !doc.id) return; |
| const sessionId = doc.session_id || _lastSessionId || null; |
| addDocToTabs(doc, sessionId); |
| |
| |
| |
| |
| |
| _ensureDocPaneMounted(); |
| |
| requestAnimationFrame(() => requestAnimationFrame(() => { |
| switchToDoc(doc.id); |
| })); |
| } |
|
|
| export async function replaceEmailReplyBody(docId, replyText) { |
| const doc = docs.get(docId); |
| if (!doc) return; |
| const fields = _parseEmailHeader(doc.content || ''); |
| const lines = String(fields.body || '').split('\n'); |
| const quoteIdx = lines.findIndex(line => |
| /^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim()) |
| || /^On .+ wrote:\s*$/i.test(line.trim()) |
| ); |
| const quote = quoteIdx >= 0 ? lines.slice(quoteIdx).join('\n') : ''; |
| const ownText = _emailReplyOwnText(fields.body || ''); |
| if (ownText && !/^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText)) { |
| if (uiModule) uiModule.showToast('AI reply ready, but draft was edited'); |
| return; |
| } |
| const body = String(replyText || '').trim() + (quote ? `\n\n${quote}` : ''); |
| doc.content = _buildEmailContent( |
| fields.to, |
| fields.subject, |
| fields.inReplyTo, |
| fields.references, |
| body, |
| fields.sourceUid, |
| fields.sourceFolder, |
| fields.cc, |
| fields.bcc, |
| ); |
| if (activeDocId === docId) { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) await _streamEmailBodyText(textarea, body); |
| } |
| clearTimeout(_autoSaveDebounce); |
| _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800); |
| } |
|
|
| |
| |
| |
| |
| function _ensureDocPaneMounted() { |
| if (!isOpen || !document.getElementById('doc-editor-pane')) { |
| isOpen = false; |
| openPanel(); |
| } |
| } |
|
|
| export async function loadDocument(docId) { |
| |
| if (docs.has(docId)) { |
| _ensureDocPaneMounted(); |
| switchToDoc(docId); |
| return; |
| } |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${docId}`); |
| if (!res.ok) throw new Error(res.status === 404 ? 'Not found' : `HTTP ${res.status}`); |
| const doc = await res.json(); |
| addDocToTabs(doc, doc.session_id); |
| _ensureDocPaneMounted(); |
| switchToDoc(doc.id); |
| } catch (e) { |
| console.error('Failed to load document:', e); |
| if (uiModule) { |
| const msg = e.message === 'Not found' |
| ? 'Document not found — try opening it from the Library.' |
| : 'Could not open document.'; |
| uiModule.showError(msg); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| function _maybeOpenDocFromHash() { |
| const m = (window.location.hash || '').match(/^#document-(.+)$/); |
| if (m) loadDocument(m[1]); |
| } |
|
|
| |
| export async function ensureDocPanel() { |
| let sessionId = _lastSessionId |
| || (sessionModule && sessionModule.getCurrentSessionId()); |
| if (!sessionId) { |
| try { |
| sessionId = await _autoCreateSession(); |
| } catch (e) { |
| console.error('Failed to auto-create session for document:', e); |
| openPanel(); |
| return; |
| } |
| } |
| await loadSessionDocs(sessionId); |
| } |
|
|
| |
| async function _autoCreateSession() { |
| |
| if (sessionModule && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) { |
| await sessionModule.materializePendingSession(); |
| const id = sessionModule.getCurrentSessionId(); |
| if (id) { _lastSessionId = id; return id; } |
| } |
| |
| const curModel = sessionModule?.getCurrentModel ? sessionModule.getCurrentModel() : null; |
| const sessions = sessionModule ? sessionModule.getSessions() : []; |
| const match = curModel && sessions.find(s => s.model === curModel && s.endpoint_url); |
| const fd = new FormData(); |
| fd.append('name', `Notes ${new Date().toLocaleTimeString()}`); |
| fd.append('skip_validation', 'true'); |
| if (match) { |
| fd.append('endpoint_url', match.endpoint_url); |
| fd.append('model', match.model); |
| if (match.endpoint_id) fd.append('endpoint_id', match.endpoint_id); |
| } |
| const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd }); |
| if (!res.ok) throw new Error('Session create failed'); |
| const payload = await res.json(); |
| const sessionId = payload.id; |
| _lastSessionId = sessionId; |
| |
| if (sessionModule && sessionModule.setCurrentSessionId) { |
| sessionModule.setCurrentSessionId(sessionId); |
| } |
| if (sessionModule && sessionModule.loadSessions) sessionModule.loadSessions(); |
| return sessionId; |
| } |
|
|
| |
| export async function loadSessionDocs(sessionId, opts = {}) { |
| _lastSessionId = sessionId; |
| const restoreMode = !!opts.restoreMode; |
| const shouldRestoreOpen = localStorage.getItem(_docOpenKey(sessionId)) === '1'; |
| const shouldRestoreMinimized = localStorage.getItem(_docMinimizedKey(sessionId)) === '1'; |
| |
| |
| for (const [id, doc] of [...docs]) { |
| if (doc.sessionId && doc.sessionId !== sessionId) docs.delete(id); |
| } |
| activeDocId = null; |
|
|
| |
| if (isOpen) _showLoadingOverlay(); |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/documents/${sessionId}`); |
| const allDocs = await res.json(); |
| _hideLoadingOverlay(); |
| |
| const activeDocs = allDocs.filter(d => d.is_active); |
| if (activeDocs.length === 0) { |
| |
| if (!restoreMode || shouldRestoreOpen) { |
| if (!isOpen) openPanel(); |
| showEmptyState(); |
| renderTabs(); |
| } |
| return; |
| } |
| for (const doc of activeDocs) { |
| if (!docs.has(doc.id)) { |
| addDocToTabs(doc, sessionId); |
| } |
| } |
| _syncDocIndicator(); |
| |
| const target = activeDocs[0]; |
| if (restoreMode && shouldRestoreMinimized && !shouldRestoreOpen) { |
| activeDocId = null; |
| _minimizedDocId = target.id; |
| _markDocVisibleState(sessionId, 'minimized'); |
| _ensureDocChipRegistered(); |
| Modals.minimize('doc-panel'); |
| return; |
| } |
| |
| |
| |
| |
| |
| |
| if (false) { |
| activeDocId = null; |
| _minimizedDocId = null; |
| if (Modals.isRegistered('doc-panel')) Modals.unregister('doc-panel'); |
| return; |
| } |
| |
| |
| |
| |
| |
| _markDocVisibleState(sessionId, 'open'); |
| if (!isOpen) openPanel(); |
| switchToDoc(target.id); |
| } catch (e) { |
| _hideLoadingOverlay(); |
| console.error('Failed to load session documents:', e); |
| |
| if (!isOpen) openPanel(); |
| showEmptyState(); |
| } |
| } |
|
|
| |
| function addDocToTabs(doc, sessionId) { |
| const existing = docs.get(doc.id); |
| docs.set(doc.id, { |
| id: doc.id, |
| title: doc.title || '', |
| language: doc.language || '', |
| content: doc.current_content || '', |
| version: doc.version_count || 1, |
| sessionId: sessionId || doc.session_id, |
| userSetLanguage: !!doc.language, |
| _composeAtts: existing?._composeAtts, |
| |
| sourceEmailUid: doc.source_email_uid || null, |
| sourceEmailFolder: doc.source_email_folder || null, |
| sourceEmailAccountId: doc.source_email_account_id || null, |
| sourceEmailMessageId: doc.source_email_message_id || null, |
| }); |
| } |
|
|
| |
| function populateEditor(doc) { |
| const titleInput = document.getElementById('doc-title-input'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const langSelect = document.getElementById('doc-language-select'); |
| const badge = document.getElementById('doc-version-badge'); |
|
|
| if (titleInput) titleInput.value = doc.title || ''; |
| if (textarea) textarea.value = doc.current_content || doc.content || ''; |
| if (langSelect) langSelect.value = doc.language || 'markdown'; |
| if (badge) { const _v = doc.version_count || doc.version || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; } |
| { const _v = doc.version_count || doc.version || 1; const _dbtn = document.getElementById('doc-diff-toggle-btn'); if (_dbtn) _dbtn.style.display = _v > 1 ? '' : 'none'; } |
| syncHighlighting(); |
| } |
|
|
| |
| function _postProcessMarkdown(codeEl) { |
| const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT); |
| const textNodes = []; |
| while (walker.nextNode()) textNodes.push(walker.currentNode); |
| for (const node of textNodes) { |
| const text = node.textContent; |
| |
| if (node.parentElement !== codeEl && node.parentElement.className && |
| /hljs-(string|link|code|section)/.test(node.parentElement.className)) continue; |
| |
| if (/\[[^\]]+\](?!\()/.test(text)) { |
| const frag = document.createDocumentFragment(); |
| let last = 0; |
| const re = /\[([^\]]+)\](?!\()/g; |
| let m; |
| while ((m = re.exec(text)) !== null) { |
| if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index))); |
| const span = document.createElement('span'); |
| span.className = 'md-bracket'; |
| span.textContent = m[0]; |
| frag.appendChild(span); |
| last = re.lastIndex; |
| } |
| if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); |
| if (last > 0) node.parentNode.replaceChild(frag, node); |
| } |
| } |
| |
| codeEl.querySelectorAll('.hljs-section').forEach(span => { |
| const text = span.textContent; |
| const hashMatch = text.match(/^(#{1,6})\s/); |
| if (hashMatch) { |
| const marker = document.createElement('span'); |
| marker.className = 'md-heading-marker'; |
| marker.textContent = hashMatch[1] + ' '; |
| span.textContent = text.slice(hashMatch[0].length); |
| span.prepend(marker); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function renderFindRects(matches, currentIdx) { |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (!wrap) return; |
| wrap.querySelectorAll('.doc-find-rect').forEach(el => el.remove()); |
| if (!matches || matches.length === 0) return; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| const text = textarea.value; |
| const style = getComputedStyle(textarea); |
| const paddingTop = parseFloat(style.paddingTop) || 10; |
| const paddingLeft = parseFloat(style.paddingLeft) || 48; |
| const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45); |
|
|
| let mirror = document.getElementById('doc-find-rect-mirror'); |
| if (!mirror) { |
| mirror = document.createElement('div'); |
| mirror.id = 'doc-find-rect-mirror'; |
| mirror.style.cssText = 'position:absolute;top:0;left:0;right:0;visibility:hidden;pointer-events:none;' + |
| 'white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;overflow:hidden;box-sizing:border-box;'; |
| wrap.appendChild(mirror); |
| } |
| mirror.style.font = style.font; |
| mirror.style.padding = style.padding; |
| mirror.style.borderWidth = style.borderWidth; |
| mirror.style.borderStyle = 'solid'; |
| mirror.style.borderColor = 'transparent'; |
| mirror.style.width = textarea.clientWidth + 'px'; |
| mirror.style.tabSize = style.tabSize; |
| mirror.style.letterSpacing = style.letterSpacing; |
| mirror.style.wordSpacing = style.wordSpacing; |
| mirror.style.textIndent = style.textIndent; |
|
|
| const scrollTop = textarea.scrollTop; |
| for (let i = 0; i < matches.length; i++) { |
| const [s, e] = matches[i]; |
| |
| |
| |
| mirror.textContent = text.substring(0, s); |
| const startTop = mirror.scrollHeight - paddingTop; |
| |
| |
| mirror.textContent = text.substring(0, e); |
| const endHeight = mirror.scrollHeight - paddingTop; |
| mirror.textContent = ''; |
|
|
| const top = paddingTop + startTop - scrollTop; |
| const height = Math.max(endHeight - startTop, lineHeight); |
| const rect = document.createElement('div'); |
| rect.className = 'doc-find-rect' + (i === currentIdx ? ' current' : ''); |
| rect.style.cssText = |
| `position:absolute;left:${paddingLeft}px;right:8px;` + |
| `top:${top}px;height:${height}px;` + |
| `pointer-events:none;z-index:6;border-radius:2px;`; |
| wrap.appendChild(rect); |
| } |
| } |
|
|
| |
| |
| |
| function applyFindMarks(codeEl) { |
| if (!codeEl) return; |
| |
| codeEl.querySelectorAll('mark.doc-find-mark').forEach(m => { |
| const parent = m.parentNode; |
| while (m.firstChild) parent.insertBefore(m.firstChild, m); |
| parent.removeChild(m); |
| parent.normalize(); |
| }); |
| const q = codeEl.dataset.findQuery || ''; |
| if (!q) return; |
| const currentIdx = parseInt(codeEl.dataset.findCurrent || '-1', 10); |
| const lq = q.toLowerCase(); |
| let occurrence = 0; |
| const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT, null); |
| const nodes = []; |
| let n; |
| while ((n = walker.nextNode())) nodes.push(n); |
| for (const node of nodes) { |
| const val = node.nodeValue || ''; |
| const lv = val.toLowerCase(); |
| if (!lv.includes(lq)) continue; |
| const frag = document.createDocumentFragment(); |
| let i = 0; |
| while (i < val.length) { |
| const hit = lv.indexOf(lq, i); |
| if (hit < 0) { frag.appendChild(document.createTextNode(val.slice(i))); break; } |
| if (hit > i) frag.appendChild(document.createTextNode(val.slice(i, hit))); |
| const mark = document.createElement('mark'); |
| mark.className = 'doc-find-mark' + (occurrence === currentIdx ? ' current' : ''); |
| mark.textContent = val.slice(hit, hit + q.length); |
| frag.appendChild(mark); |
| occurrence++; |
| i = hit + q.length; |
| } |
| node.parentNode.replaceChild(frag, node); |
| } |
| } |
|
|
| |
| function syncHighlighting() { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const codeEl = document.getElementById('doc-editor-code'); |
| const pre = document.getElementById('doc-editor-highlight'); |
| if (!textarea || !codeEl) return; |
|
|
| |
| if (codeEl.dataset.hasDiff) return; |
|
|
| const text = textarea.value; |
| |
| codeEl.textContent = text + '\n'; |
|
|
| const lang = document.getElementById('doc-language-select')?.value; |
| |
| |
| const _hlLang = lang === 'svg' ? 'xml' : lang; |
| codeEl.className = _hlLang ? `language-${_hlLang}` : ''; |
| if (window.hljs && _hlLang) { |
| codeEl.removeAttribute('data-highlighted'); |
| window.hljs.highlightElement(codeEl); |
| } |
| |
| if (lang === 'markdown') { |
| _postProcessMarkdown(codeEl); |
| } |
|
|
| |
| if (codeEl.dataset.findQuery) applyFindMarks(codeEl); |
|
|
| |
| if (pre) { |
| codeEl.style.minHeight = textarea.scrollHeight + 'px'; |
| pre.scrollTop = textarea.scrollTop; |
| pre.scrollLeft = textarea.scrollLeft; |
| } |
|
|
| |
| updateLineNumbers(text); |
| } |
|
|
| |
| let _lineNumberResizeObserver = null; |
| let _lineNumberObservedTextarea = null; |
| let _lineNumberResizeRaf = null; |
|
|
| function _lineNumberContentEl(gutter) { |
| let inner = gutter.querySelector('.doc-line-number-content'); |
| if (!inner) { |
| inner = document.createElement('div'); |
| inner.className = 'doc-line-number-content'; |
| gutter.textContent = ''; |
| gutter.appendChild(inner); |
| } |
| return inner; |
| } |
|
|
| function _lineNumberStyleSignature(style) { |
| return [ |
| style.fontFamily, |
| style.fontSize, |
| style.fontWeight, |
| style.fontStyle, |
| style.lineHeight, |
| style.letterSpacing, |
| style.tabSize, |
| style.fontFeatureSettings, |
| style.fontVariantLigatures, |
| style.fontKerning, |
| ].join('|'); |
| } |
|
|
| function _textareaTextWidth(textarea, style) { |
| const paddingLeft = parseFloat(style.paddingLeft) || 0; |
| const paddingRight = parseFloat(style.paddingRight) || 0; |
| return Math.max(0, textarea.clientWidth - paddingLeft - paddingRight); |
| } |
|
|
| function _lineHeightPx(style) { |
| const parsed = parseFloat(style.lineHeight); |
| if (Number.isFinite(parsed) && parsed > 0) return parsed; |
| const fontSize = parseFloat(style.fontSize) || 11; |
| return fontSize * 1.45; |
| } |
|
|
| function _lineNumberMeasureEl(textarea) { |
| const wrap = document.getElementById('doc-editor-wrap') || textarea.parentElement || document.body; |
| let probe = wrap.querySelector('.doc-line-number-measure'); |
| if (!probe) { |
| probe = document.createElement('textarea'); |
| probe.className = 'doc-line-number-measure'; |
| probe.setAttribute('aria-hidden', 'true'); |
| probe.tabIndex = -1; |
| probe.readOnly = true; |
| probe.wrap = 'soft'; |
| wrap.appendChild(probe); |
| } |
| return probe; |
| } |
|
|
| function _syncLineNumberMeasureStyle(probe, style, textWidth) { |
| probe.style.width = textWidth + 'px'; |
| probe.style.fontFamily = style.fontFamily; |
| probe.style.fontSize = style.fontSize; |
| probe.style.fontWeight = style.fontWeight; |
| probe.style.fontStyle = style.fontStyle; |
| probe.style.lineHeight = style.lineHeight; |
| probe.style.letterSpacing = style.letterSpacing; |
| probe.style.tabSize = style.tabSize; |
| probe.style.fontFeatureSettings = style.fontFeatureSettings; |
| probe.style.fontVariantLigatures = style.fontVariantLigatures; |
| probe.style.fontKerning = style.fontKerning; |
| probe.style.textRendering = style.textRendering; |
| probe.style.whiteSpace = style.whiteSpace; |
| probe.style.wordWrap = style.wordWrap; |
| probe.style.overflowWrap = style.overflowWrap; |
| } |
|
|
| function _measureLineNumberHeights(textarea, lines, textWidth, style) { |
| const probe = _lineNumberMeasureEl(textarea); |
| _syncLineNumberMeasureStyle(probe, style, textWidth); |
| const lineHeight = _lineHeightPx(style); |
| return lines.map(line => { |
| probe.value = line || ' '; |
| const visualRows = Math.max(1, Math.round(probe.scrollHeight / lineHeight)); |
| return visualRows * lineHeight; |
| }); |
| } |
|
|
| function _renderLineNumberRows(inner, heights) { |
| const frag = document.createDocumentFragment(); |
| for (let i = 0; i < heights.length; i++) { |
| const row = document.createElement('div'); |
| row.className = 'doc-line-number-row'; |
| row.style.height = `${heights[i]}px`; |
|
|
| const label = document.createElement('span'); |
| label.className = 'doc-line-number-label'; |
| label.textContent = String(i + 1); |
| row.appendChild(label); |
| frag.appendChild(row); |
| } |
| inner.textContent = ''; |
| inner.appendChild(frag); |
| } |
|
|
| function _scheduleLineNumberRerender() { |
| if (_lineNumberResizeRaf) return; |
| const run = () => { |
| _lineNumberResizeRaf = null; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) updateLineNumbers(textarea.value, true); |
| }; |
| if (typeof requestAnimationFrame === 'function') { |
| _lineNumberResizeRaf = requestAnimationFrame(run); |
| } else { |
| run(); |
| } |
| } |
|
|
| function _ensureLineNumberResizeObserver(textarea) { |
| if (typeof ResizeObserver === 'undefined') return; |
| if (!_lineNumberResizeObserver) { |
| _lineNumberResizeObserver = new ResizeObserver(_scheduleLineNumberRerender); |
| } |
| if (_lineNumberObservedTextarea === textarea) return; |
| if (_lineNumberObservedTextarea) { |
| _lineNumberResizeObserver.unobserve(_lineNumberObservedTextarea); |
| } |
| _lineNumberObservedTextarea = textarea; |
| _lineNumberResizeObserver.observe(textarea); |
| } |
|
|
| if (typeof window !== 'undefined') { |
| window.addEventListener('resize', _scheduleLineNumberRerender); |
| } |
|
|
| function updateLineNumbers(text, force = false) { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const gutter = document.getElementById('doc-line-numbers'); |
| if (!textarea || !gutter) return; |
|
|
| const value = text || ''; |
| const lines = value.split('\n'); |
| const inner = _lineNumberContentEl(gutter); |
| const style = getComputedStyle(textarea); |
| const textWidth = _textareaTextWidth(textarea, style); |
| const styleSig = _lineNumberStyleSignature(style); |
|
|
| _ensureLineNumberResizeObserver(textarea); |
| if ( |
| !force && |
| inner._lineNumberText === value && |
| inner._lineNumberWidth === textWidth && |
| inner._lineNumberStyleSig === styleSig |
| ) { |
| syncGutterScroll(); |
| return; |
| } |
|
|
| const heights = _measureLineNumberHeights(textarea, lines, textWidth, style); |
| _renderLineNumberRows(inner, heights); |
| inner._lineNumberText = value; |
| inner._lineNumberWidth = textWidth; |
| inner._lineNumberStyleSig = styleSig; |
| syncGutterScroll(); |
| } |
|
|
| |
| function syncGutterScroll() { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const gutter = document.getElementById('doc-line-numbers'); |
| if (textarea && gutter) { |
| _lineNumberContentEl(gutter).style.transform = `translateY(${-textarea.scrollTop}px)`; |
| } |
| } |
|
|
| |
| |
| function _looksLikeMarkdown(text) { |
| const lines = text.slice(0, 2000).split('\n'); |
| let score = 0; |
| for (const line of lines) { |
| if (/^#{1,6}\s/.test(line)) score += 3; |
| else if (/^\s*[-*+]\s/.test(line)) score += 1; |
| else if (/^\s*\d+\.\s/.test(line)) score += 1; |
| else if (/^\s*>/.test(line)) score += 1; |
| else if (/\[.+\]\(.+\)/.test(line)) score += 2; |
| else if (/^```/.test(line)) score += 2; |
| else if (/\*\*.+\*\*/.test(line)) score += 1; |
| else if (/^---\s*$/.test(line)) score += 1; |
| } |
| return score >= 3; |
| } |
|
|
| function attemptAutoDetect() { |
| if (!window.hljs || !activeDocId) return; |
| const doc = docs.get(activeDocId); |
| if (!doc || doc.userSetLanguage) return; |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
|
|
| const text = textarea.value; |
| if (text.length < AUTO_DETECT_MIN_CHARS) return; |
|
|
| |
| |
| |
| if (/^\s*(<\?xml[^>]*>\s*)?(<!doctype[^>]*>\s*)?<svg[\s>]/i.test(text)) { |
| const langSelect = document.getElementById('doc-language-select'); |
| if (langSelect && langSelect.value !== 'svg') { |
| langSelect.value = 'svg'; |
| doc.language = 'svg'; |
| updateLanguage(); |
| syncHighlighting(); |
| _syncHeaderActions(); |
| } |
| return; |
| } |
|
|
| |
| if (_looksLikeMarkdown(text)) { |
| const langSelect = document.getElementById('doc-language-select'); |
| if (langSelect && langSelect.value !== 'markdown') { |
| langSelect.value = 'markdown'; |
| doc.language = 'markdown'; |
| updateLanguage(); |
| syncHighlighting(); |
| _syncHeaderActions(); |
| const mdToolbar = document.getElementById('doc-md-toolbar'); |
| if (mdToolbar) { mdToolbar.style.display = ''; if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); } |
| } |
| return; |
| } |
|
|
| const sample = text.slice(0, AUTO_DETECT_SAMPLE_SIZE); |
| const result = window.hljs.highlightAuto(sample); |
|
|
| if (!result.language || result.relevance < AUTO_DETECT_MIN_RELEVANCE) return; |
|
|
| const mapped = HLJS_TO_DROPDOWN[result.language]; |
| if (!mapped) return; |
|
|
| const langSelect = document.getElementById('doc-language-select'); |
| if (!langSelect || langSelect.value === mapped) return; |
|
|
| langSelect.value = mapped; |
| doc.language = mapped; |
| updateLanguage(); |
| syncHighlighting(); |
| _syncHeaderActions(); |
|
|
| const mdToolbar2 = document.getElementById('doc-md-toolbar'); |
| if (mdToolbar2) mdToolbar2.style.display = (mapped === 'markdown') ? '' : 'none'; |
| } |
|
|
| |
|
|
| |
| let _selections = []; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| let _selResizeScheduled = false; |
| function _scheduleSelRerender() { |
| if (_selResizeScheduled || _selections.length === 0) return; |
| _selResizeScheduled = true; |
| requestAnimationFrame(() => { |
| _selResizeScheduled = false; |
| try { renderAllSelectionHighlights(); } catch (_) {} |
| }); |
| } |
| if (typeof window !== 'undefined') { |
| window.addEventListener('resize', _scheduleSelRerender); |
| } |
| |
| |
| |
| |
| let _selResizeObserver = null; |
| function _ensureSelResizeObserver() { |
| if (_selResizeObserver || typeof ResizeObserver === 'undefined') return; |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (!ta) return; |
| _selResizeObserver = new ResizeObserver(_scheduleSelRerender); |
| _selResizeObserver.observe(ta); |
| } |
|
|
| |
| |
| |
| |
| |
| function _textareaWraps(ta) { |
| if (!ta) return false; |
| const style = getComputedStyle(ta); |
| const lh = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45); |
| if (!lh) return false; |
| const padTop = parseFloat(style.paddingTop) || 0; |
| const padBottom = parseFloat(style.paddingBottom) || 0; |
| const renderedRows = Math.round((ta.scrollHeight - padTop - padBottom) / lh); |
| const logicalLines = (ta.value || '').split('\n').length; |
| return renderedRows > logicalLines; |
| } |
|
|
| |
| |
| function updateSelectionState() { |
| |
| |
| |
| |
| |
| |
| const _pane = document.querySelector('.doc-editor-pane'); |
| const _isFs = !!(_pane && _pane.classList.contains('doc-fullscreen')); |
| const _ta0 = document.getElementById('doc-editor-textarea'); |
| if (!_isFs && _textareaWraps(_ta0)) { |
| if (_selections.length) clearSelection(); |
| return; |
| } |
| _ensureSelResizeObserver(); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
|
|
| const start = textarea.selectionStart; |
| const end = textarea.selectionEnd; |
|
|
| if (start === end) { |
| |
| return; |
| } |
|
|
| const text = textarea.value; |
| const selectedText = text.substring(start, end); |
| const startLine = text.substring(0, start).split('\n').length; |
| const endLine = text.substring(0, end).split('\n').length; |
|
|
| |
| const overlapIdx = _selections.findIndex(s => |
| (start >= s.start && start <= s.end) || (end >= s.start && end <= s.end) || |
| (start <= s.start && end >= s.end) |
| ); |
| const entry = { text: selectedText, startLine, endLine, start, end }; |
| if (overlapIdx >= 0) { |
| _selections[overlapIdx] = entry; |
| } else { |
| _selections.push(entry); |
| } |
|
|
| showSelectionBadge(); |
| renderAllSelectionHighlights(); |
| } |
|
|
| |
| function showSelectionBadge() { |
| let badge = document.getElementById('doc-selection-badge'); |
| if (!badge) { |
| badge = document.createElement('span'); |
| badge.id = 'doc-selection-badge'; |
| badge.className = 'doc-selection-badge'; |
| badge.title = 'Selected regions — type in chat to edit'; |
| |
| |
| |
| const toolbar = document.getElementById('doc-md-toolbar'); |
| if (toolbar && toolbar.parentNode) { |
| toolbar.insertAdjacentElement('afterend', badge); |
| } else { |
| const header = document.querySelector('.doc-editor-header'); |
| if (header) header.insertBefore(badge, header.firstChild); |
| } |
| } |
| if (_selections.length === 0) { |
| badge.style.display = 'none'; |
| return; |
| } |
| const labels = _selections.map(s => |
| s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}-${s.endLine}` |
| ); |
| const label = _selections.length === 1 |
| ? `${labels[0]} selected` |
| : `${_selections.length} selections (${labels.join(', ')})`; |
| badge.innerHTML = `${label}<button class="doc-selection-clear" title="Clear all selections">×</button>`; |
| badge.style.display = ''; |
| badge.querySelector('.doc-selection-clear').addEventListener('click', (e) => { |
| e.stopPropagation(); |
| clearSelection(); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| function _isCodeDoc() { |
| const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); |
| if (!lang) return false; |
| |
| const prose = new Set(['markdown', 'md', 'text', 'txt', 'email', 'html', 'csv']); |
| return !prose.has(lang); |
| } |
|
|
| |
| |
| |
| |
| function _measurePos(mirror, text, pos) { |
| mirror.innerHTML = ''; |
| if (pos > 0) mirror.appendChild(document.createTextNode(text.substring(0, pos))); |
| const marker = document.createElement('span'); |
| marker.textContent = ''; |
| mirror.appendChild(marker); |
| const r = marker.getBoundingClientRect(); |
| const m = mirror.getBoundingClientRect(); |
| return { x: r.left - m.left, y: r.top - m.top }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function _validateSelections(text) { |
| if (_selections.length === 0) return; |
| const survivors = []; |
| for (const s of _selections) { |
| const captured = s.text || ''; |
| if (!captured) continue; |
| |
| if (text.substring(s.start, s.end) === captured) { |
| survivors.push(s); |
| continue; |
| } |
| |
| |
| |
| let best = -1, bestDist = Infinity; |
| let from = 0; |
| while (true) { |
| const idx = text.indexOf(captured, from); |
| if (idx === -1) break; |
| const dist = Math.abs(idx - s.start); |
| if (dist < bestDist) { best = idx; bestDist = dist; } |
| from = idx + 1; |
| } |
| if (best === -1) continue; |
| const newStart = best; |
| const newEnd = best + captured.length; |
| survivors.push({ |
| ...s, |
| start: newStart, |
| end: newEnd, |
| startLine: text.substring(0, newStart).split('\n').length, |
| endLine: text.substring(0, newEnd).split('\n').length, |
| }); |
| } |
| _selections = survivors; |
| } |
|
|
| function renderAllSelectionHighlights() { |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (!wrap) return; |
| |
| wrap.querySelectorAll('.doc-selection-overlay').forEach(el => el.remove()); |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea || _selections.length === 0) return; |
|
|
| const text = textarea.value; |
| |
| |
| |
| _validateSelections(text); |
| if (_selections.length === 0) return; |
| const style = getComputedStyle(textarea); |
| const paddingTop = parseFloat(style.paddingTop) || 10; |
| const paddingLeft = parseFloat(style.paddingLeft) || 48; |
| const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45); |
|
|
| |
| |
| let mirror = document.getElementById('doc-selection-mirror'); |
| if (!mirror) { |
| mirror = document.createElement('div'); |
| mirror.id = 'doc-selection-mirror'; |
| |
| |
| |
| |
| |
| |
| mirror.style.cssText = 'position:absolute;top:0;left:0;right:0;visibility:hidden;pointer-events:none;' + |
| 'white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;overflow:hidden;box-sizing:border-box;'; |
| wrap.appendChild(mirror); |
| } |
| mirror.style.font = style.font; |
| mirror.style.padding = style.padding; |
| mirror.style.borderWidth = style.borderWidth; |
| mirror.style.borderStyle = 'solid'; |
| mirror.style.borderColor = 'transparent'; |
| mirror.style.width = textarea.clientWidth + 'px'; |
| mirror.style.tabSize = style.tabSize; |
| mirror.style.letterSpacing = style.letterSpacing; |
| mirror.style.wordSpacing = style.wordSpacing; |
| mirror.style.textIndent = style.textIndent; |
|
|
| const codeDoc = _isCodeDoc(); |
| const scrollTop = textarea.scrollTop; |
|
|
| for (const sel of _selections) { |
| if (codeDoc) { |
| |
| const beforeStart = text.substring(0, sel.start); |
| const lastNewline = beforeStart.lastIndexOf('\n'); |
| const startLineBegin = lastNewline + 1; |
| mirror.textContent = text.substring(0, startLineBegin); |
| const startTop = mirror.scrollHeight - paddingTop; |
|
|
| const afterEnd = text.indexOf('\n', sel.end); |
| const endLineEnd = afterEnd === -1 ? text.length : afterEnd; |
| mirror.textContent = text.substring(0, endLineEnd); |
| const endBottom = mirror.scrollHeight - paddingTop; |
|
|
| mirror.textContent = ''; |
|
|
| const top = paddingTop + startTop - scrollTop; |
| const height = endBottom - startTop || lineHeight; |
| const overlay = document.createElement('div'); |
| overlay.className = 'doc-selection-overlay'; |
| overlay.style.top = top + 'px'; |
| overlay.style.left = paddingLeft + 'px'; |
| overlay.style.right = '0'; |
| overlay.style.height = height + 'px'; |
| wrap.appendChild(overlay); |
| } else { |
| |
| |
| |
| |
| const startPos = _measurePos(mirror, text, sel.start); |
| const endPos = _measurePos(mirror, text, sel.end); |
| mirror.innerHTML = ''; |
|
|
| const addRect = (top, left, width, height) => { |
| const overlay = document.createElement('div'); |
| overlay.className = 'doc-selection-overlay'; |
| overlay.style.top = (paddingTop + top - scrollTop) + 'px'; |
| overlay.style.left = (paddingLeft + left) + 'px'; |
| if (width != null) overlay.style.width = width + 'px'; |
| else overlay.style.right = '0'; |
| overlay.style.height = height + 'px'; |
| wrap.appendChild(overlay); |
| }; |
|
|
| if (Math.abs(endPos.y - startPos.y) < 1) { |
| |
| addRect(startPos.y, startPos.x, endPos.x - startPos.x, lineHeight); |
| } else { |
| |
| addRect(startPos.y, startPos.x, null, lineHeight); |
| |
| const middleTop = startPos.y + lineHeight; |
| const middleHeight = endPos.y - middleTop; |
| if (middleHeight > 0) addRect(middleTop, 0, null, middleHeight); |
| |
| addRect(endPos.y, 0, endPos.x, lineHeight); |
| } |
| } |
| } |
| } |
|
|
| |
| function syncSelectionOverlay() { |
| if (_selections.length === 0) return; |
| renderAllSelectionHighlights(); |
| } |
|
|
| |
| function clearSelection() { |
| _selections = []; |
| const badge = document.getElementById('doc-selection-badge'); |
| if (badge) badge.style.display = 'none'; |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap) wrap.querySelectorAll('.doc-selection-overlay').forEach(el => el.remove()); |
| } |
|
|
| |
| |
| |
| |
| |
| export function getSelectionContext() { |
| if (_selections.length === 0) return null; |
| |
| |
| |
| const _ta = document.getElementById('doc-editor-textarea'); |
| if (_ta) _validateSelections(_ta.value); |
| if (_selections.length === 0) return null; |
| if (_selections.length === 1) { |
| const ctx = _selections[0]; |
| clearSelection(); |
| return ctx; |
| } |
| |
| const ctx = [..._selections]; |
| clearSelection(); |
| return ctx; |
| } |
|
|
| |
|
|
| let _activeSuggestions = []; |
|
|
| |
| function _saveSuggestionsToStorage() { |
| if (!activeDocId) return; |
| const data = _activeSuggestions.map(s => ({ id: s.id, find: s.find, replace: s.replace, reason: s.reason })); |
| if (data.length) { |
| localStorage.setItem('odysseus-suggestions-' + activeDocId, JSON.stringify(data)); |
| } else { |
| localStorage.removeItem('odysseus-suggestions-' + activeDocId); |
| } |
| } |
|
|
| |
| function _restoreSuggestionsFromStorage(docId) { |
| try { |
| const raw = localStorage.getItem('odysseus-suggestions-' + docId); |
| if (!raw) return; |
| const data = JSON.parse(raw); |
| if (!Array.isArray(data) || !data.length) return; |
| _activeSuggestions = data.map(s => ({ id: s.id, find: s.find, replace: s.replace, reason: s.reason, cardEl: null })); |
| _suggestionTotal = _activeSuggestions.length; |
| _suggestionIndex = 0; |
| _showCurrentSuggestion(); |
| } catch {} |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function handleDocSuggestions(data) { |
| if (_diffModeActive) exitDiffMode(true); |
| if (!data.suggestions || !data.suggestions.length) return; |
|
|
| if (!isOpen) openPanel(); |
| if (data.doc_id && data.doc_id !== activeDocId) switchToDoc(data.doc_id); |
|
|
| const hadPending = _activeSuggestions.length > 0; |
| const existingIds = new Set(_activeSuggestions.map(s => s.id)); |
|
|
| |
| |
| let added = 0; |
| for (const sugg of data.suggestions) { |
| if (existingIds.has(sugg.id)) continue; |
| _activeSuggestions.push({ |
| id: sugg.id, |
| find: sugg.find, |
| replace: sugg.replace, |
| reason: sugg.reason, |
| cardEl: null, |
| }); |
| added++; |
| } |
| _suggestionTotal = (_suggestionTotal || 0) + added; |
|
|
| _saveSuggestionsToStorage(); |
|
|
| |
| |
| |
| if (!hadPending) { |
| _suggestionIndex = 0; |
| _showCurrentSuggestion(); |
| } else { |
| |
| |
| const active = document.getElementById('doc-suggestion-active'); |
| if (active) { |
| const counter = active.querySelector('.doc-suggestion-counter'); |
| if (counter) { |
| const num = _suggestionTotal - _activeSuggestions.length + 1; |
| counter.textContent = `${num} / ${_suggestionTotal}`; |
| } |
| } |
| } |
| } |
|
|
| |
| function _showCurrentSuggestion() { |
| const wrap = document.getElementById('doc-editor-wrap'); |
| const pane = document.querySelector('.doc-editor-pane'); |
| if (!wrap || !pane) return; |
|
|
| |
| const old = document.getElementById('doc-suggestion-active'); |
| if (old) { if (old._cleanup) old._cleanup(); old.remove(); } |
| _clearSuggestionHighlight(); |
| _clearInlineDiff(); |
|
|
| if (_activeSuggestions.length === 0) { |
| return; |
| } |
|
|
| const sugg = _activeSuggestions[0]; |
| const remaining = _activeSuggestions.length; |
| const num = _suggestionTotal - remaining + 1; |
|
|
| |
| _showInlineDiff(sugg.find, sugg.replace); |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
|
|
| |
| if (textarea) { |
| const text = textarea.value; |
| const idx = text.indexOf(sugg.find); |
| if (idx >= 0) { |
| const lineNum = text.substring(0, idx).split('\n').length - 1; |
| const lineH = parseFloat(getComputedStyle(textarea).lineHeight) || 20; |
| const target = Math.max(0, lineNum * lineH - (textarea.clientHeight / 3)); |
| textarea.scrollTop = target; |
| } |
| } |
|
|
| |
| function _positionCard(card) { |
| if (!textarea) return; |
| const text = textarea.value; |
| const idx = text.indexOf(sugg.find); |
| if (idx < 0) return; |
|
|
| const linesBefore = text.substring(0, idx).split('\n').length - 1; |
| const lineH = parseFloat(getComputedStyle(textarea).lineHeight) || 20; |
| const textareaRect = textarea.getBoundingClientRect(); |
| const paddingTop = parseFloat(getComputedStyle(textarea).paddingTop) || 10; |
| const rawTop = textareaRect.top + paddingTop + (linesBefore * lineH) - textarea.scrollTop; |
| const clampedTop = Math.max(60, Math.min(rawTop, window.innerHeight - 220)); |
| card.style.position = 'fixed'; |
| card.style.top = clampedTop + 'px'; |
|
|
| const paneRect = pane.getBoundingClientRect(); |
| const isMobile = window.innerWidth <= 768; |
| if (!isMobile) { |
| if (paneRect.right + 270 < window.innerWidth) { |
| card.style.left = (paneRect.right + 16) + 'px'; |
| card.style.right = ''; |
| } else { |
| card.style.left = ''; |
| card.style.right = (window.innerWidth - paneRect.left + 16) + 'px'; |
| } |
| } |
|
|
| |
| _clearSuggestionHighlight(); |
| _highlightSuggestionText(sugg.find); |
| } |
|
|
| |
| const card = document.createElement('div'); |
| card.id = 'doc-suggestion-active'; |
| card.className = 'doc-suggestion-card'; |
|
|
| card.innerHTML = ` |
| <div class="doc-suggestion-header"> |
| <div class="doc-suggestion-nav"> |
| <button class="doc-suggestion-nav-btn doc-suggestion-prev" title="Previous">‹</button> |
| <span class="doc-suggestion-counter">${num} / ${_suggestionTotal}</span> |
| <button class="doc-suggestion-nav-btn doc-suggestion-next" title="Next">›</button> |
| </div> |
| <button class="doc-suggestion-close" title="Close all suggestions">×</button> |
| </div> |
| <div class="doc-suggestion-reason">${_esc(sugg.reason)}</div> |
| <div class="doc-suggestion-actions"> |
| <button class="doc-suggestion-accept">Accept</button> |
| <button class="doc-suggestion-dismiss">Skip</button> |
| ${remaining > 1 ? '<button class="doc-suggestion-accept-all">Accept All</button>' : ''} |
| </div> |
| `; |
|
|
| |
| card.querySelector('.doc-suggestion-close').addEventListener('click', clearAllSuggestions); |
| card.querySelector('.doc-suggestion-prev').addEventListener('click', () => { |
| const current = _activeSuggestions.shift(); |
| _activeSuggestions.push(current); |
| const prev = _activeSuggestions.pop(); |
| _activeSuggestions.unshift(prev); |
| _suggestionIndex = (_suggestionIndex - 1 + _suggestionTotal) % _suggestionTotal; |
| _showCurrentSuggestion(); |
| }); |
| card.querySelector('.doc-suggestion-next').addEventListener('click', () => { |
| const current = _activeSuggestions.shift(); |
| _activeSuggestions.push(current); |
| _suggestionIndex = (_suggestionIndex + 1) % _suggestionTotal; |
| _showCurrentSuggestion(); |
| }); |
| card.querySelector('.doc-suggestion-accept').addEventListener('click', () => { |
| _applySuggestion(sugg); |
| _activeSuggestions.shift(); |
| _animateNext(); |
| }); |
| card.querySelector('.doc-suggestion-dismiss').addEventListener('click', () => { |
| _activeSuggestions.shift(); |
| _animateNext(); |
| }); |
| const acceptAllBtn = card.querySelector('.doc-suggestion-accept-all'); |
| if (acceptAllBtn) { |
| acceptAllBtn.addEventListener('click', () => { |
| for (const s of _activeSuggestions) _applySuggestion(s); |
| _activeSuggestions = []; |
| _animateNext(); |
| }); |
| } |
|
|
| sugg.cardEl = card; |
| document.body.appendChild(card); |
|
|
| |
| requestAnimationFrame(() => _positionCard(card)); |
|
|
| |
| const _reposition = () => { if (card.isConnected) _positionCard(card); }; |
| if (textarea) textarea.addEventListener('scroll', _reposition); |
| window.addEventListener('resize', _reposition); |
| |
| card._cleanup = () => { |
| if (textarea) textarea.removeEventListener('scroll', _reposition); |
| window.removeEventListener('resize', _reposition); |
| }; |
| } |
|
|
| |
| function _showInlineDiff(findText, replaceText) { |
| const codeEl = document.getElementById('doc-editor-code'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!codeEl || !textarea) return; |
|
|
| const text = textarea.value; |
| const idx = text.indexOf(findText); |
| if (idx === -1) return; |
|
|
| const before = text.substring(0, idx); |
| const after = text.substring(idx + findText.length); |
|
|
| |
| let cPre = 0; |
| while (cPre < findText.length && cPre < replaceText.length && findText[cPre] === replaceText[cPre]) cPre++; |
| let cSuf = 0; |
| while (cSuf < (findText.length - cPre) && cSuf < (replaceText.length - cPre) && |
| findText[findText.length - 1 - cSuf] === replaceText[replaceText.length - 1 - cSuf]) cSuf++; |
|
|
| const commonBefore = findText.substring(0, cPre); |
| const commonAfter = findText.substring(findText.length - cSuf); |
| const delPart = findText.substring(cPre, findText.length - cSuf); |
| const addPart = replaceText.substring(cPre, replaceText.length - cSuf); |
|
|
| |
| codeEl.innerHTML = ''; |
| codeEl.appendChild(document.createTextNode(before)); |
| if (commonBefore) codeEl.appendChild(document.createTextNode(commonBefore)); |
|
|
| if (delPart) { |
| const del = document.createElement('span'); |
| del.className = 'sugg-inline-del'; |
| del.textContent = delPart; |
| codeEl.appendChild(del); |
| } |
| if (addPart) { |
| const add = document.createElement('span'); |
| add.className = 'sugg-inline-add'; |
| add.textContent = addPart; |
| codeEl.appendChild(add); |
| } |
|
|
| if (commonAfter) codeEl.appendChild(document.createTextNode(commonAfter)); |
| codeEl.appendChild(document.createTextNode(after + '\n')); |
|
|
| |
| codeEl.dataset.hasDiff = '1'; |
| } |
|
|
| |
| function _clearInlineDiff() { |
| const codeEl = document.getElementById('doc-editor-code'); |
| if (codeEl && codeEl.dataset.hasDiff) { |
| delete codeEl.dataset.hasDiff; |
| syncHighlighting(); |
| } |
| } |
|
|
| |
|
|
| const DIFF_MODE_THRESHOLD = 3; |
|
|
| |
| function _computeLineDiff(oldText, newText) { |
| const oldLines = oldText.split('\n'); |
| const newLines = newText.split('\n'); |
| const m = oldLines.length, n = newLines.length; |
|
|
| |
| const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1)); |
| for (let i = 1; i <= m; i++) { |
| for (let j = 1; j <= n; j++) { |
| dp[i][j] = oldLines[i - 1] === newLines[j - 1] |
| ? dp[i - 1][j - 1] + 1 |
| : Math.max(dp[i - 1][j], dp[i][j - 1]); |
| } |
| } |
|
|
| |
| const entries = []; |
| let i = m, j = n; |
| while (i > 0 || j > 0) { |
| if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { |
| entries.push({ type: 'equal', line: oldLines[i - 1] }); |
| i--; j--; |
| } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { |
| entries.push({ type: 'insert', line: newLines[j - 1] }); |
| j--; |
| } else { |
| entries.push({ type: 'delete', line: oldLines[i - 1] }); |
| i--; |
| } |
| } |
| entries.reverse(); |
| return entries; |
| } |
|
|
| |
| function _buildDiffChunks(entries) { |
| const chunks = []; |
| let chunkId = 0; |
| let lineIdx = 0; |
| let i = 0; |
| while (i < entries.length) { |
| const e = entries[i]; |
| if (e.type === 'equal') { |
| lineIdx++; |
| i++; |
| } else { |
| |
| const startLine = lineIdx; |
| const oldLines = [], newLines = []; |
| while (i < entries.length && entries[i].type !== 'equal') { |
| if (entries[i].type === 'delete') oldLines.push(entries[i].line); |
| else newLines.push(entries[i].line); |
| i++; |
| } |
| chunks.push({ |
| id: chunkId++, |
| oldLines, |
| newLines, |
| startLine, |
| resolved: false, |
| accepted: false, |
| }); |
| lineIdx += oldLines.length + newLines.length; |
| } |
| } |
| return chunks; |
| } |
|
|
| |
| function enterDiffMode(oldContent, newContent) { |
| if (_diffModeActive) exitDiffMode(true); |
|
|
| _diffModeActive = true; |
| _diffOldContent = oldContent; |
| _diffNewContent = newContent; |
|
|
| const entries = _computeLineDiff(oldContent, newContent); |
| _diffChunks = _buildDiffChunks(entries); |
| _diffUnresolvedCount = _diffChunks.length; |
|
|
| if (_diffChunks.length === 0) { |
| _diffModeActive = false; |
| if (uiModule) uiModule.showToast('No changes'); |
| return; |
| } |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) textarea.readOnly = true; |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap) wrap.classList.add('diff-mode'); |
|
|
| _renderDiffOverlay(entries); |
| _renderDiffToolbar(); |
| _renderDiffGutter(); |
|
|
| |
| const diffBtn = document.getElementById('doc-diff-toggle-btn'); |
| if (diffBtn) diffBtn.classList.add('active'); |
| } |
|
|
| |
| function _renderDiffOverlay(entries) { |
| const codeEl = document.getElementById('doc-editor-code'); |
| const gutter = document.getElementById('doc-line-numbers'); |
| if (!codeEl) return; |
|
|
| codeEl.innerHTML = ''; |
| let gutterHtml = ''; |
| let oldNum = 0, newNum = 0; |
|
|
| |
| let chunkIdx = 0; |
| let entryIdx = 0; |
| const entryChunkMap = new Array(entries.length).fill(-1); |
| while (entryIdx < entries.length) { |
| if (entries[entryIdx].type === 'equal') { |
| entryIdx++; |
| } else { |
| |
| const cid = chunkIdx < _diffChunks.length ? _diffChunks[chunkIdx].id : -1; |
| while (entryIdx < entries.length && entries[entryIdx].type !== 'equal') { |
| entryChunkMap[entryIdx] = cid; |
| entryIdx++; |
| } |
| chunkIdx++; |
| } |
| } |
|
|
| for (let i = 0; i < entries.length; i++) { |
| const e = entries[i]; |
| if (e.type === 'equal') { |
| oldNum++; newNum++; |
| const el = document.createElement('span'); |
| el.className = 'diff-line-equal'; |
| el.textContent = e.line + '\n'; |
| codeEl.appendChild(el); |
| gutterHtml += newNum + '\n'; |
| } else if (e.type === 'delete') { |
| oldNum++; |
| const el = document.createElement('span'); |
| el.className = 'diff-line-del'; |
| if (entryChunkMap[i] >= 0) el.dataset.chunkId = entryChunkMap[i]; |
| el.textContent = e.line + '\n'; |
| codeEl.appendChild(el); |
| gutterHtml += '−\n'; |
| } else { |
| newNum++; |
| const el = document.createElement('span'); |
| el.className = 'diff-line-add'; |
| if (entryChunkMap[i] >= 0) el.dataset.chunkId = entryChunkMap[i]; |
| el.textContent = e.line + '\n'; |
| codeEl.appendChild(el); |
| gutterHtml += '+\n'; |
| } |
| } |
|
|
| if (gutter) gutter.textContent = gutterHtml; |
| codeEl.dataset.hasDiff = '1'; |
|
|
| |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) { |
| const allLines = entries.map(e => e.line); |
| textarea.value = allLines.join('\n') + '\n'; |
| } |
| } |
|
|
| |
| function _renderDiffToolbar() { |
| let toolbar = document.getElementById('doc-diff-toolbar'); |
| if (toolbar) toolbar.remove(); |
|
|
| toolbar = document.createElement('div'); |
| toolbar.id = 'doc-diff-toolbar'; |
| toolbar.className = 'diff-toolbar'; |
|
|
| const status = document.createElement('span'); |
| status.className = 'diff-toolbar-status'; |
| status.id = 'diff-toolbar-status'; |
| _updateDiffStatus(status); |
|
|
| const acceptAll = document.createElement('button'); |
| acceptAll.className = 'diff-toolbar-btn diff-toolbar-btn-accept'; |
| acceptAll.textContent = 'Accept All'; |
| acceptAll.addEventListener('click', () => _resolveAllChunks(true)); |
|
|
| const rejectAll = document.createElement('button'); |
| rejectAll.className = 'diff-toolbar-btn diff-toolbar-btn-reject'; |
| rejectAll.textContent = 'Reject All'; |
| rejectAll.addEventListener('click', () => _resolveAllChunks(false)); |
|
|
| toolbar.appendChild(status); |
| toolbar.appendChild(acceptAll); |
| toolbar.appendChild(rejectAll); |
|
|
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap) wrap.parentNode.insertBefore(toolbar, wrap); |
| } |
|
|
| |
| function _renderDiffGutter() { |
| let gutterEl = document.getElementById('doc-diff-gutter'); |
| if (gutterEl) gutterEl.remove(); |
|
|
| gutterEl = document.createElement('div'); |
| gutterEl.id = 'doc-diff-gutter'; |
| gutterEl.className = 'diff-gutter'; |
|
|
| const codeEl = document.getElementById('doc-editor-code'); |
| if (!codeEl) return; |
|
|
| |
| |
| requestAnimationFrame(() => { |
| for (const chunk of _diffChunks) { |
| if (chunk.resolved) continue; |
| const firstEl = codeEl.querySelector(`[data-chunk-id="${chunk.id}"]`); |
| if (!firstEl) continue; |
|
|
| const actions = document.createElement('span'); |
| actions.className = 'diff-chunk-actions'; |
| actions.dataset.chunkId = chunk.id; |
|
|
| const acceptBtn = document.createElement('button'); |
| acceptBtn.className = 'diff-chunk-btn diff-chunk-btn-accept'; |
| acceptBtn.title = 'Accept change'; |
| acceptBtn.innerHTML = '✓'; |
| acceptBtn.addEventListener('click', (e) => { e.stopPropagation(); _resolveChunk(chunk.id, true); }); |
|
|
| const rejectBtn = document.createElement('button'); |
| rejectBtn.className = 'diff-chunk-btn diff-chunk-btn-reject'; |
| rejectBtn.title = 'Reject change'; |
| rejectBtn.innerHTML = '✗'; |
| rejectBtn.addEventListener('click', (e) => { e.stopPropagation(); _resolveChunk(chunk.id, false); }); |
|
|
| actions.appendChild(acceptBtn); |
| actions.appendChild(rejectBtn); |
|
|
| |
| firstEl.style.position = 'relative'; |
| firstEl.appendChild(actions); |
| } |
| }); |
| } |
|
|
| |
| function _updateDiffStatus(statusEl) { |
| const el = statusEl || document.getElementById('diff-toolbar-status'); |
| if (!el) return; |
| const resolved = _diffChunks.length - _diffUnresolvedCount; |
| el.textContent = `${resolved} / ${_diffChunks.length} changes resolved`; |
| } |
|
|
| |
| function _resolveChunk(chunkId, accept) { |
| const chunk = _diffChunks.find(c => c.id === chunkId); |
| if (!chunk || chunk.resolved) return; |
|
|
| chunk.resolved = true; |
| chunk.accepted = accept; |
| _diffUnresolvedCount--; |
|
|
| |
| const codeEl = document.getElementById('doc-editor-code'); |
| if (codeEl) { |
| codeEl.querySelectorAll(`[data-chunk-id="${chunkId}"]`).forEach(el => { |
| el.classList.add('diff-chunk-resolved'); |
| }); |
| } |
|
|
| |
| const gutterActions = document.querySelector(`.diff-chunk-actions[data-chunk-id="${chunkId}"]`); |
| if (gutterActions) gutterActions.remove(); |
|
|
| _updateDiffStatus(); |
|
|
| |
| _applyResolvedChunksToTextarea(); |
| saveDocument({ silent: true }); |
|
|
| if (_diffUnresolvedCount === 0) { |
| setTimeout(() => exitDiffMode(false), 300); |
| } |
| } |
|
|
| |
| |
| function _applyResolvedChunksToTextarea() { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| const entries = _computeLineDiff(_diffOldContent || '', _diffNewContent || ''); |
| const result = []; |
| let chunkIdx = 0; |
| let i = 0; |
| while (i < entries.length) { |
| if (entries[i].type === 'equal') { |
| result.push(entries[i].line); |
| i++; |
| } else { |
| const chunk = _diffChunks[chunkIdx++]; |
| const chunkOld = [], chunkNew = []; |
| while (i < entries.length && entries[i].type !== 'equal') { |
| if (entries[i].type === 'delete') chunkOld.push(entries[i].line); |
| else chunkNew.push(entries[i].line); |
| i++; |
| } |
| |
| if (chunk && chunk.resolved && chunk.accepted) { |
| result.push(...chunkNew); |
| } else { |
| result.push(...chunkOld); |
| } |
| } |
| } |
| textarea.value = result.join('\n'); |
| } |
|
|
| |
| function _resolveAllChunks(accept) { |
| for (const chunk of _diffChunks) { |
| if (!chunk.resolved) { |
| chunk.resolved = true; |
| chunk.accepted = accept; |
| } |
| } |
| _diffUnresolvedCount = 0; |
| exitDiffMode(false); |
| } |
|
|
| |
| function exitDiffMode(discard) { |
| if (!_diffModeActive) return; |
| _diffModeActive = false; |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| const codeEl = document.getElementById('doc-editor-code'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap) wrap.classList.remove('diff-mode'); |
|
|
| if (discard) { |
| |
| if (textarea) textarea.value = _diffOldContent || ''; |
| } else { |
| |
| const oldLines = (_diffOldContent || '').split('\n'); |
| const newLines = (_diffNewContent || '').split('\n'); |
| const entries = _computeLineDiff(_diffOldContent || '', _diffNewContent || ''); |
|
|
| const result = []; |
| let chunkIdx = 0; |
| let i = 0; |
| while (i < entries.length) { |
| if (entries[i].type === 'equal') { |
| result.push(entries[i].line); |
| i++; |
| } else { |
| |
| const chunk = _diffChunks[chunkIdx++]; |
| |
| const chunkOld = [], chunkNew = []; |
| while (i < entries.length && entries[i].type !== 'equal') { |
| if (entries[i].type === 'delete') chunkOld.push(entries[i].line); |
| else chunkNew.push(entries[i].line); |
| i++; |
| } |
| if (chunk && chunk.accepted) { |
| result.push(...chunkNew); |
| } else { |
| result.push(...chunkOld); |
| } |
| } |
| } |
| if (textarea) textarea.value = result.join('\n'); |
| } |
|
|
| |
| if (textarea) textarea.readOnly = false; |
| if (codeEl) delete codeEl.dataset.hasDiff; |
|
|
| |
| const toolbar = document.getElementById('doc-diff-toolbar'); |
| if (toolbar) toolbar.remove(); |
| document.querySelectorAll('.diff-chunk-actions').forEach(el => el.remove()); |
|
|
| |
| _diffOldContent = null; |
| _diffNewContent = null; |
| _diffChunks = []; |
| _diffUnresolvedCount = 0; |
|
|
| const diffBtn = document.getElementById('doc-diff-toggle-btn'); |
| if (diffBtn) diffBtn.classList.remove('active'); |
|
|
| syncHighlighting(); |
| updateLineNumbers(textarea ? textarea.value : ''); |
| saveDocument({ silent: true }); |
| } |
|
|
| |
| function isDiffModeActive() { return _diffModeActive; } |
|
|
| let _suggestionTotal = 0; |
| let _suggestionIndex = 0; |
|
|
| |
| const _origHandleDocSuggestions = handleDocSuggestions; |
| |
|
|
| |
| function _applySuggestion(sugg) { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea && sugg.find && textarea.value.includes(sugg.find)) { |
| textarea.value = textarea.value.replace(sugg.find, sugg.replace); |
| syncHighlighting(); |
| saveDocument({ silent: true }); |
| } |
| } |
|
|
| |
| function _animateNext() { |
| _saveSuggestionsToStorage(); |
| const old = document.getElementById('doc-suggestion-active'); |
| if (old) { |
| if (old._cleanup) old._cleanup(); |
| old.style.transition = 'opacity 0.15s, transform 0.15s'; |
| old.style.opacity = '0'; |
| old.style.transform = 'translateY(-10px)'; |
| setTimeout(() => { |
| old.remove(); |
| _showCurrentSuggestion(); |
| }, 150); |
| } else { |
| _showCurrentSuggestion(); |
| } |
| } |
|
|
| function _esc(s) { |
| return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| } |
|
|
| |
| function acceptSuggestion(id) { |
| const sugg = _activeSuggestions.find(s => s.id === id); |
| if (!sugg) return; |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea && sugg.find && textarea.value.includes(sugg.find)) { |
| textarea.value = textarea.value.replace(sugg.find, sugg.replace); |
| syncHighlighting(); |
| saveDocument({ silent: true }); |
| } |
|
|
| |
| sugg.cardEl.style.transition = 'opacity 0.2s, transform 0.2s'; |
| sugg.cardEl.style.opacity = '0'; |
| sugg.cardEl.style.transform = 'translateX(10px)'; |
| setTimeout(() => sugg.cardEl.remove(), 200); |
|
|
| _activeSuggestions = _activeSuggestions.filter(s => s.id !== id); |
| _clearSuggestionHighlight(); |
|
|
| |
| if (_activeSuggestions.length === 0) { |
| const container = document.getElementById('doc-suggestions-container'); |
| if (container) container.style.display = 'none'; |
| } |
| } |
|
|
| |
| function dismissSuggestion(id) { |
| const sugg = _activeSuggestions.find(s => s.id === id); |
| if (!sugg) return; |
|
|
| sugg.cardEl.style.transition = 'opacity 0.15s'; |
| sugg.cardEl.style.opacity = '0'; |
| setTimeout(() => sugg.cardEl.remove(), 150); |
|
|
| _activeSuggestions = _activeSuggestions.filter(s => s.id !== id); |
| _clearSuggestionHighlight(); |
|
|
| if (_activeSuggestions.length === 0) { |
| const container = document.getElementById('doc-suggestions-container'); |
| if (container) container.style.display = 'none'; |
| } |
| } |
|
|
| |
| function clearAllSuggestions() { |
| _activeSuggestions = []; |
| _suggestionTotal = 0; |
| _saveSuggestionsToStorage(); |
| _clearSuggestionHighlight(); |
| _clearInlineDiff(); |
| const old = document.getElementById('doc-suggestion-active'); |
| if (old) { if (old._cleanup) old._cleanup(); old.remove(); } |
| const container = document.getElementById('doc-suggestions-container'); |
| if (container) { container.innerHTML = ''; container.style.display = 'none'; } |
| |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) updateLineNumbers(ta.value); |
| } |
|
|
| |
| function _highlightSuggestionText(findText) { |
| _clearSuggestionHighlight(); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (!textarea || !wrap) return; |
|
|
| const text = textarea.value; |
| const idx = text.indexOf(findText); |
| if (idx === -1) return; |
|
|
| const style = getComputedStyle(textarea); |
| const paddingTop = parseFloat(style.paddingTop) || 10; |
| const paddingLeft = parseFloat(style.paddingLeft) || 48; |
| const lineHeight = parseFloat(style.lineHeight) || 20; |
|
|
| let mirror = document.getElementById('doc-selection-mirror'); |
| if (!mirror) return; |
|
|
| const beforeStart = text.substring(0, idx); |
| const lastNewline = beforeStart.lastIndexOf('\n'); |
| const startLineBegin = lastNewline + 1; |
| mirror.textContent = text.substring(0, startLineBegin); |
| const startTop = mirror.scrollHeight - paddingTop; |
|
|
| const endIdx = idx + findText.length; |
| const afterEnd = text.indexOf('\n', endIdx); |
| const endLineEnd = afterEnd === -1 ? text.length : afterEnd; |
| mirror.textContent = text.substring(0, endLineEnd); |
| const endBottom = mirror.scrollHeight - paddingTop; |
| mirror.textContent = ''; |
|
|
| const top = paddingTop + startTop - textarea.scrollTop; |
| const height = Math.max(endBottom - startTop, lineHeight); |
|
|
| const highlight = document.createElement('div'); |
| highlight.className = 'doc-suggestion-highlight'; |
| highlight.id = 'doc-suggestion-hover-hl'; |
| highlight.style.top = top + 'px'; |
| highlight.style.left = paddingLeft + 'px'; |
| highlight.style.right = '0'; |
| highlight.style.height = height + 'px'; |
| wrap.appendChild(highlight); |
|
|
| |
| } |
|
|
| |
| function _clearSuggestionHighlight() { |
| const hl = document.getElementById('doc-suggestion-hover-hl'); |
| if (hl) hl.remove(); |
| } |
|
|
| |
| function runDocument() { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea || !textarea.value.trim()) return; |
|
|
| const code = textarea.value; |
| const langSelect = document.getElementById('doc-language-select'); |
| const lang = (langSelect ? langSelect.value : '').toLowerCase(); |
|
|
| |
| let outputPanel = document.getElementById('doc-run-output'); |
| if (!outputPanel) { |
| outputPanel = document.createElement('div'); |
| outputPanel.id = 'doc-run-output'; |
| outputPanel.className = 'doc-run-output'; |
| const editorWrap = document.getElementById('doc-editor-wrap'); |
| if (editorWrap) editorWrap.after(outputPanel); |
| } |
| outputPanel.style.display = 'block'; |
| outputPanel.innerHTML = ''; |
|
|
| if (_isRenderLang(lang)) { |
| |
| outputPanel.style.display = 'none'; |
| toggleHtmlPreview(); |
| return; |
| } |
|
|
| if (!codeRunnerModule) { |
| outputPanel.innerHTML = '<pre class="doc-run-error">Code runner not loaded</pre>'; |
| setTimeout(() => { if (outputPanel) outputPanel.style.display = 'none'; }, 5000); |
| return; |
| } |
|
|
| if (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') { |
| codeRunnerModule.runServer(code, outputPanel, 'bash'); |
| return; |
| } |
|
|
| if (lang === 'python' || lang === 'py') { |
| codeRunnerModule.runServer(code, outputPanel, 'python'); |
| return; |
| } |
|
|
| if (lang === 'javascript' || lang === 'js') { |
| codeRunnerModule.runJavaScript(code, outputPanel); |
| return; |
| } |
|
|
| outputPanel.innerHTML = '<pre class="doc-run-error">Unsupported language. Supported: bash, python, javascript, html</pre>'; |
| setTimeout(() => { if (outputPanel) outputPanel.style.display = 'none'; }, 5000); |
| } |
|
|
| |
| async function copyDocument() { |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea || !textarea.value) return; |
| if (uiModule && uiModule.copyToClipboard) { |
| await uiModule.copyToClipboard(textarea.value); |
| } else { |
| try { |
| await navigator.clipboard.writeText(textarea.value); |
| } catch (e) { } |
| } |
| if (uiModule) uiModule.showToast('Copied to clipboard'); |
| } |
|
|
| |
|
|
| let _docTabMenu = null; |
|
|
| function _closeDocTabMenu() { |
| if (_docTabMenu) { _docTabMenu.style.display = 'none'; } |
| } |
|
|
| function showDocTabMenu(btnEl, docId) { |
| |
| if (_docTabMenu && _docTabMenu.style.display === 'block' && _docTabMenu._docId === docId) { |
| _closeDocTabMenu(); |
| return; |
| } |
|
|
| |
| const _menuAnchorRect = btnEl.getBoundingClientRect(); |
|
|
| |
| if (docId !== activeDocId) switchToDoc(docId); |
|
|
| const doc = docs.get(docId); |
| if (!doc) return; |
|
|
| |
| if (!_docTabMenu) { |
| _docTabMenu = document.createElement('div'); |
| _docTabMenu.className = 'doc-tab-dropdown'; |
| _docTabMenu.style.cssText = 'position:fixed;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:none;'; |
| document.body.appendChild(_docTabMenu); |
| |
| document.addEventListener('click', (e) => { |
| if (_docTabMenu && !_docTabMenu.contains(e.target) && !e.target.closest('.doc-tab-menu-btn')) { |
| _closeDocTabMenu(); |
| } |
| }); |
| document.addEventListener('keydown', (e) => { |
| if (e.key !== 'Escape' || !_docTabMenu || _docTabMenu.style.display !== 'block') return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| e.stopImmediatePropagation?.(); |
| _closeDocTabMenu(); |
| }, true); |
| } |
|
|
| const lang = (doc.language || '').toLowerCase(); |
| const canRun = _isRenderLang(lang) || ['javascript', 'js', 'python', 'py', 'bash', 'sh', 'shell', 'zsh'].includes(lang); |
|
|
| let previewIcon = '', previewLabel = ''; |
| const _mdPreview = document.getElementById('doc-md-preview'); |
| const _csvPreview = document.getElementById('doc-csv-preview'); |
| const _htmlPreview = document.getElementById('doc-html-preview'); |
| const _mdActive = _mdPreview && _mdPreview.style.display !== 'none'; |
| const _csvActive = _csvPreview && _csvPreview.style.display !== 'none'; |
| const _htmlActive = _htmlPreview && _htmlPreview.style.display !== 'none'; |
| if (lang === 'markdown') { previewIcon = 'MD'; previewLabel = _mdActive ? 'Edit' : 'Preview'; } |
| else if (lang === 'csv') { previewIcon = '⊞'; previewLabel = _csvActive ? 'Edit' : 'Table View'; } |
| else if (_isRenderLang(lang)) { previewIcon = '▶'; previewLabel = _htmlActive ? 'Edit' : 'Run / Preview'; } |
|
|
| const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`; |
| const _saveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>'; |
| const _copyIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; |
| const _runIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>'; |
| const _previewIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; |
| const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>'; |
|
|
| let items = ''; |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="save">${_di(_saveIco)}<span>Save</span></div>`; |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="copy">${_di(_copyIco)}<span>Copy</span></div>`; |
| if (canRun) { |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="run">${_di(_runIco)}<span>Run</span></div>`; |
| } |
| if (previewLabel) { |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="preview"><span class="dropdown-icon">${previewIcon}</span><span>${previewLabel}</span></div>`; |
| } |
| const _downloadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'; |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="download">${_di(_downloadIco)}<span>Download</span></div>`; |
| |
| if (doc.sourceEmailUid && doc.sourceEmailFolder) { |
| const _sendBackIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>'; |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="signed-reply">${_di(_sendBackIco)}<span>Send signed reply</span></div>`; |
| } |
| const _closeIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'; |
| items += `<div class="dropdown-item-compact doc-tab-action" data-action="close">${_di(_closeIco)}<span>Close</span></div>`; |
| items += `<div class="dropdown-divider"></div>`; |
| items += `<div class="dropdown-item-compact doc-tab-action doc-tab-action-delete" data-action="delete">${_di(_deleteIco)}<span>Delete</span></div>`; |
|
|
| _docTabMenu.innerHTML = items; |
| _docTabMenu.style.display = 'block'; |
| _docTabMenu._docId = docId; |
|
|
| |
| const rect = _menuAnchorRect; |
| const tabBar = document.getElementById('doc-tab-bar'); |
| const barBottom = tabBar ? tabBar.getBoundingClientRect().bottom : rect.bottom; |
| _docTabMenu.style.position = 'fixed'; |
| _docTabMenu.style.zIndex = '1000'; |
| _docTabMenu.style.left = rect.left + 'px'; |
| _docTabMenu.style.top = (barBottom + 2) + 'px'; |
|
|
| |
| requestAnimationFrame(() => { |
| const menuRect = _docTabMenu.getBoundingClientRect(); |
| if (menuRect.right > window.innerWidth - 8) { |
| _docTabMenu.style.left = (window.innerWidth - menuRect.width - 8) + 'px'; |
| } |
| if (menuRect.left < 8) { |
| _docTabMenu.style.left = '8px'; |
| } |
| if (menuRect.bottom > window.innerHeight - 8) { |
| _docTabMenu.style.top = (barBottom - menuRect.height - 4) + 'px'; |
| } |
| }); |
|
|
| |
| _docTabMenu.querySelectorAll('.doc-tab-action').forEach(item => { |
| item.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const action = item.dataset.action; |
| _closeDocTabMenu(); |
| switch (action) { |
| case 'save': saveDocument(); break; |
| case 'copy': copyDocument(); break; |
| case 'run': runDocument(); break; |
| case 'preview': |
| if (lang === 'markdown') toggleMarkdownPreview(); |
| else if (lang === 'csv') toggleCsvPreview(); |
| else if (_isRenderLang(lang)) toggleHtmlPreview(); |
| break; |
| case 'download': { |
| const btn = document.getElementById('doc-fontsize-btn') || document.getElementById('doc-language-select'); |
| showExportMenu(null, btn?.getBoundingClientRect()); |
| break; |
| } |
| case 'signed-reply': _sendSignedReply(docId); break; |
| case 'close': closeTab(docId); break; |
| case 'delete': deleteActiveDocument(); break; |
| } |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function _sendSignedReply(docId) { |
| const doc = docs.get(docId); |
| if (!doc || !doc.sourceEmailUid) return; |
| if (uiModule) uiModule.showToast('Preparing signed reply…'); |
| let result; |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${encodeURIComponent(docId)}/prepare-signed-reply`, { |
| method: 'POST', |
| credentials: 'same-origin', |
| }); |
| result = await res.json().catch(() => ({})); |
| if (!res.ok || !result.ok) { |
| const msg = (result && result.error) || `HTTP ${res.status}`; |
| if (uiModule) uiModule.showError(`Couldn't prepare signed reply: ${msg}`); |
| return; |
| } |
| } catch (e) { |
| console.error('prepare-signed-reply failed:', e); |
| if (uiModule) uiModule.showError("Couldn't prepare signed reply"); |
| return; |
| } |
|
|
| const att = result.attachment; |
| const reply = result.reply || {}; |
| const mid = reply.source_message_id || doc.sourceEmailMessageId || ''; |
|
|
| |
| for (const [, d] of docs) { |
| if (d.language === 'email' && d._draftForMessageId === mid && mid) { |
| d._composeAtts = (d._composeAtts || []).concat([att]); |
| await loadDocument(d.id); |
| _renderComposeAttachments(); |
| if (uiModule) uiModule.showToast(`Added "${att.filename}" to the reply draft`); |
| return; |
| } |
| } |
|
|
| |
| const headerLines = [ |
| `To: ${reply.to || ''}`, |
| `Subject: ${reply.subject || ''}`, |
| reply.in_reply_to ? `In-Reply-To: ${reply.in_reply_to}` : null, |
| reply.references ? `References: ${reply.references}` : null, |
| reply.source_uid ? `X-Source-UID: ${reply.source_uid}` : null, |
| reply.source_folder ? `X-Source-Folder: ${reply.source_folder}` : null, |
| ].filter(Boolean); |
| const content = headerLines.join('\n') + '\n---\n\nHi' + (reply.to_name ? ' ' + reply.to_name.split(/\s+/)[0] : '') + ',\n\nPlease find the signed copy attached.\n\nBest,\n'; |
|
|
| let draftId = null; |
| try { |
| |
| let sessionId = doc.sessionId |
| || _lastSessionId |
| || (sessionModule && sessionModule.getCurrentSessionId()); |
| if (!sessionId) { |
| try { sessionId = await _autoCreateSession(); } catch (_) {} |
| } |
| const cRes = await fetch(`${API_BASE}/api/document`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| credentials: 'same-origin', |
| body: JSON.stringify({ |
| session_id: sessionId, |
| title: reply.subject || 'Signed reply', |
| language: 'email', |
| content, |
| }), |
| }); |
| const created = await cRes.json(); |
| draftId = created && (created.id || created.doc_id); |
| if (!draftId) throw new Error('No draft id returned'); |
| } catch (e) { |
| console.error('Failed to create draft doc:', e); |
| if (uiModule) uiModule.showError("Couldn't create reply draft"); |
| return; |
| } |
|
|
| |
| |
| addDocToTabs({ |
| id: draftId, |
| title: reply.subject || 'Signed reply', |
| language: 'email', |
| current_content: content, |
| version_count: 1, |
| }, doc.sessionId); |
| const draft = docs.get(draftId); |
| if (draft) { |
| draft._composeAtts = [att]; |
| draft._draftForMessageId = mid; |
| if (reply.account_id) draft._draftAccountId = reply.account_id; |
| } |
|
|
| await loadDocument(draftId); |
| _renderComposeAttachments(); |
| if (uiModule) uiModule.showToast(`Reply draft ready — "${att.filename}" attached`); |
| } |
|
|
| |
| export async function saveDocument({ silent = false } = {}) { |
| if (!activeDocId) return; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ content: textarea.value }), |
| }); |
| const doc = await res.json(); |
| const badge = document.getElementById('doc-version-badge'); |
| if (badge) { const _v = doc.version_count || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; } |
| |
| if (docs.has(activeDocId)) { |
| docs.get(activeDocId).version = doc.version_count || 1; |
| docs.get(activeDocId).content = textarea.value; |
| } |
| _syncDocIndicator(); |
| if (!silent && uiModule) uiModule.showToast('Document saved'); |
| } catch (e) { |
| console.error('Failed to save document:', e); |
| if (!silent && uiModule) uiModule.showError('Failed to save document'); |
| } |
| } |
|
|
| |
| let _docxReady = null; |
| function ensureDocx() { |
| if (_docxReady) return _docxReady; |
| if (window.docx) return (_docxReady = Promise.resolve()); |
| _docxReady = new Promise((resolve, reject) => { |
| const s = document.createElement('script'); |
| s.src = '/static/lib/docx.umd.min.js'; |
| s.onload = resolve; |
| s.onerror = () => reject(new Error('Failed to load DOCX library')); |
| document.head.appendChild(s); |
| }); |
| return _docxReady; |
| } |
|
|
| let _html2pdfReady = null; |
| function ensureHtml2Pdf() { |
| if (_html2pdfReady) return _html2pdfReady; |
| if (window.html2pdf) return (_html2pdfReady = Promise.resolve()); |
| _html2pdfReady = new Promise((resolve, reject) => { |
| const s = document.createElement('script'); |
| s.src = '/static/lib/html2pdf.bundle.min.js'; |
| s.onload = resolve; |
| s.onerror = () => reject(new Error('Failed to load PDF library')); |
| document.head.appendChild(s); |
| }); |
| return _html2pdfReady; |
| } |
|
|
| function _getExportBaseName() { |
| const doc = docs.get(activeDocId); |
| const title = (doc && doc.title) || 'document'; |
| const safeName = title.replace(/[^a-zA-Z0-9_\-. ]/g, '_').trim() || 'document'; |
| const ver = doc && doc.version ? `_v${doc.version}` : ''; |
| return safeName + ver; |
| } |
|
|
| function exportDocument() { |
| if (!activeDocId) return; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| const doc = docs.get(activeDocId); |
| const title = (doc && doc.title) || 'document'; |
| const lang = document.getElementById('doc-language-select')?.value || ''; |
| const extMap = { |
| javascript: '.js', python: '.py', html: '.html', css: '.css', |
| markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', |
| sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', csharp: '.cs', |
| typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt', |
| xml: '.xml', toml: '.toml', ini: '.ini', csv: '.csv', |
| }; |
| const ext = extMap[lang] || '.txt'; |
| const safeName = title.replace(/[^a-zA-Z0-9_\-. ]/g, '_').trim() || 'document'; |
| const ver = doc && doc.version ? `_v${doc.version}` : ''; |
| const mime = lang === 'csv' ? 'text/csv' : lang === 'json' ? 'application/json' : 'text/plain'; |
| const blob = new Blob([textarea.value], { type: mime }); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = safeName + ver + ext; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function _importFromDevice() { |
| const EXT_TO_LANG = { |
| '.py':'python','.js':'javascript','.ts':'typescript','.html':'html','.htm':'html', |
| '.css':'css','.md':'markdown','.json':'json','.yml':'yaml','.yaml':'yaml', |
| '.sh':'bash','.bash':'bash','.sql':'sql','.rs':'rust','.go':'go', |
| '.java':'java','.c':'c','.cpp':'cpp','.h':'c','.hpp':'cpp', |
| '.rb':'ruby','.php':'php','.xml':'xml','.toml':'toml','.ini':'ini', |
| '.txt':'','.log':'','.csv':'csv','.tsv':'csv','.jsx':'javascript','.tsx':'typescript', |
| }; |
| const fi = document.createElement('input'); |
| fi.type = 'file'; |
| fi.style.display = 'none'; |
| fi.addEventListener('change', async () => { |
| const file = fi.files?.[0]; |
| if (!file) return; |
| const name = file.name; |
| const dotIdx = name.lastIndexOf('.'); |
| const ext = dotIdx >= 0 ? name.slice(dotIdx).toLowerCase() : ''; |
| const baseTitle = dotIdx > 0 ? name.slice(0, dotIdx) : name; |
| const isSpreadsheet = ['.xlsx','.xls','.ods'].includes(ext); |
| const isPdf = ext === '.pdf'; |
| |
| if (isSpreadsheet) { |
| openLibrary(); |
| requestAnimationFrame(() => requestAnimationFrame(() => document.getElementById('doclib-import-file-btn')?.click())); |
| return; |
| } |
| try { |
| let docId = null; |
| if (isPdf) { |
| const fd = new FormData(); |
| fd.append('file', file); |
| const sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()) || _lastSessionId || ''; |
| if (sid) fd.append('session_id', sid); |
| const r = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' }); |
| if (!r.ok) throw new Error('PDF import failed'); |
| const j = await r.json(); |
| docId = j.doc_id || j.id; |
| } else { |
| const content = await new Promise((res, rej) => { |
| const reader = new FileReader(); |
| reader.onload = () => res(reader.result || ''); |
| reader.onerror = () => rej(reader.error); |
| reader.readAsText(file); |
| }); |
| const lang = EXT_TO_LANG[ext] !== undefined ? EXT_TO_LANG[ext] : null; |
| const sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()) || _lastSessionId || ''; |
| const body = { title: baseTitle, language: lang, content }; |
| if (sid) body.session_id = sid; |
| const r = await fetch(`${API_BASE}/api/document`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| credentials: 'same-origin', |
| body: JSON.stringify(body), |
| }); |
| if (!r.ok) throw new Error('Import failed'); |
| const j = await r.json(); |
| docId = j.id || j.doc_id; |
| } |
| if (docId) { |
| |
| |
| try { |
| const dr = await fetch(`${API_BASE}/api/document/${docId}`, { credentials: 'same-origin' }); |
| const full = dr.ok ? await dr.json() : { id: docId, title: baseTitle }; |
| const sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()) || _lastSessionId || ''; |
| addDocToTabs(full, full.session_id || sid); |
| switchToDoc(full.id || docId); |
| } catch (_) { |
| |
| addDocToTabs({ id: docId, title: baseTitle }, _lastSessionId || ''); |
| switchToDoc(docId); |
| } |
| } |
| } catch (err) { |
| if (uiModule && uiModule.showError) uiModule.showError('Import failed: ' + (err.message || err)); |
| } finally { |
| fi.value = ''; |
| fi.remove(); |
| } |
| }); |
| document.body.appendChild(fi); |
| fi.click(); |
| } |
|
|
| function showExportMenu(e, anchorRect) { |
| if (e) e.stopPropagation(); |
| |
| const existing = document.getElementById('doc-export-menu'); |
| if (existing) { existing.remove(); return; } |
|
|
| |
| const rect = anchorRect |
| || (e && e.target && e.target.closest('button')?.getBoundingClientRect()) |
| || document.getElementById('doc-language-select')?.getBoundingClientRect(); |
| if (!rect) return; |
|
|
| const lang = document.getElementById('doc-language-select')?.value || ''; |
| const extMap = { |
| javascript: '.js', python: '.py', html: '.html', css: '.css', |
| markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', |
| sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', csharp: '.cs', |
| typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt', |
| xml: '.xml', toml: '.toml', ini: '.ini', csv: '.csv', |
| }; |
| const ext = extMap[lang] || '.txt'; |
|
|
| const menu = document.createElement('div'); |
| menu.id = 'doc-export-menu'; |
| menu.className = 'doc-overflow-menu open'; |
| menu.style.position = 'fixed'; |
| menu.style.top = (rect.bottom + 2) + 'px'; |
| menu.style.right = (window.innerWidth - rect.right) + 'px'; |
| menu.style.left = 'auto'; |
| menu.style.zIndex = '9999'; |
|
|
| const langLabel = lang ? lang.toUpperCase() : 'TXT'; |
| |
| |
| const liveContent = document.getElementById('doc-editor-textarea')?.value |
| || docs.get(activeDocId)?.content || ''; |
| const isForm = _isFormBackedDoc(liveContent); |
| const options = []; |
| |
| |
| |
| options.push({ label: 'Import from library', fn: () => openLibrary() }); |
| options.push({ label: 'Import from device', fn: () => _importFromDevice(), _divider: true }); |
| if (isForm) options.push({ label: 'Filled PDF (.pdf)', fn: _downloadFilledPdf }); |
| options.push( |
| { label: 'Export Markdown', fn: exportDocument }, |
| { label: 'Print as PDF', fn: exportAsPdf }, |
| { label: 'Export as Word', fn: exportAsDocx }, |
| ); |
|
|
| options.forEach(opt => { |
| const item = document.createElement('button'); |
| item.className = 'doc-overflow-item'; |
| item.textContent = opt.label; |
| item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); }); |
| menu.appendChild(item); |
| if (opt._divider) { |
| const sep = document.createElement('div'); |
| sep.className = 'doc-overflow-divider'; |
| sep.style.cssText = 'height:1px;margin:3px 6px;background:color-mix(in srgb,var(--border) 60%,transparent);'; |
| menu.appendChild(sep); |
| } |
| }); |
|
|
| document.body.appendChild(menu); |
| |
| |
| const mh = menu.offsetHeight; |
| if (rect.bottom + mh > window.innerHeight - 8) { |
| menu.style.top = 'auto'; |
| menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; |
| } |
| const close = (ev) => { |
| if (ev && ev.type === 'keydown') { |
| if (ev.key !== 'Escape') return; |
| ev.preventDefault(); |
| ev.stopPropagation(); |
| ev.stopImmediatePropagation?.(); |
| } else if (ev && menu.contains(ev.target)) { |
| return; |
| } |
| menu.remove(); |
| document.removeEventListener('click', close); |
| document.removeEventListener('keydown', close, true); |
| }; |
| setTimeout(() => document.addEventListener('click', close), 100); |
| document.addEventListener('keydown', close, true); |
| } |
|
|
| function exportAsHtml() { |
| if (!activeDocId) return; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| const lang = document.getElementById('doc-language-select')?.value || ''; |
| const text = textarea.value || ''; |
| let body; |
| if (lang === 'markdown' && markdownModule?.mdToHtml) { |
| body = markdownModule.mdToHtml(text); |
| } else { |
| body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' + |
| text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>'; |
| } |
| const title = docs.get(activeDocId)?.title || 'document'; |
| const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>${title.replace(/</g,'<')}</title></head><body style="max-width:800px;margin:40px auto;font-family:sans-serif;line-height:1.6;padding:0 20px;">\n${body}\n</body></html>`; |
| const blob = new Blob([html], { type: 'text/html' }); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = _getExportBaseName() + '.html'; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| if (uiModule) uiModule.showToast('Exported as HTML'); |
| } |
|
|
| async function exportAsPdf() { |
| if (!activeDocId) return; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| try { |
| await ensureHtml2Pdf(); |
| } catch (e) { |
| if (uiModule) uiModule.showError('Failed to load PDF library'); |
| return; |
| } |
| const lang = document.getElementById('doc-language-select')?.value || ''; |
| const text = textarea.value || ''; |
| |
| let html; |
| if (lang === 'markdown' && markdownModule?.mdToHtml) { |
| html = markdownModule.mdToHtml(text); |
| } else { |
| html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' + |
| text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>'; |
| } |
| const container = document.createElement('div'); |
| container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;'; |
| container.innerHTML = html; |
| const baseName = _getExportBaseName(); |
| window.html2pdf().set({ |
| margin: 10, |
| filename: baseName + '.pdf', |
| image: { type: 'jpeg', quality: 0.95 }, |
| html2canvas: { scale: 2 }, |
| jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, |
| }).from(container).save(); |
| if (uiModule) uiModule.showToast('Exporting PDF...'); |
| } |
|
|
| async function exportAsDocx() { |
| if (!activeDocId) return; |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!textarea) return; |
| try { |
| await ensureDocx(); |
| } catch (e) { |
| if (uiModule) uiModule.showError('Failed to load DOCX library'); |
| return; |
| } |
| const text = textarea.value || ''; |
| const { Document, Packer, Paragraph, TextRun, HeadingLevel } = window.docx; |
| |
| const paragraphs = text.split('\n').map(line => { |
| const h1 = line.match(/^# (.+)/); |
| const h2 = line.match(/^## (.+)/); |
| const h3 = line.match(/^### (.+)/); |
| if (h1) return new Paragraph({ text: h1[1], heading: HeadingLevel.HEADING_1 }); |
| if (h2) return new Paragraph({ text: h2[1], heading: HeadingLevel.HEADING_2 }); |
| if (h3) return new Paragraph({ text: h3[1], heading: HeadingLevel.HEADING_3 }); |
| |
| const runs = []; |
| const parts = line.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/); |
| for (const part of parts) { |
| if (part.startsWith('**') && part.endsWith('**')) { |
| runs.push(new TextRun({ text: part.slice(2, -2), bold: true })); |
| } else if (part.startsWith('*') && part.endsWith('*')) { |
| runs.push(new TextRun({ text: part.slice(1, -1), italics: true })); |
| } else { |
| runs.push(new TextRun(part)); |
| } |
| } |
| return new Paragraph({ children: runs }); |
| }); |
|
|
| const doc = new Document({ |
| sections: [{ children: paragraphs }], |
| }); |
| const blob = await Packer.toBlob(doc); |
| const baseName = _getExportBaseName(); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = baseName + '.docx'; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| if (uiModule) uiModule.showToast('Exported as DOCX'); |
| } |
|
|
| |
| async function deleteActiveDocument() { |
| if (!activeDocId) return; |
| const doc = docs.get(activeDocId); |
| const name = doc ? doc.title : 'this document'; |
| const ok = uiModule && uiModule.styledConfirm |
| ? await uiModule.styledConfirm(`Delete "${name}"?`, { confirmText: 'Delete', danger: true }) |
| : confirm(`Delete "${name}"?`); |
| if (!ok) return; |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, { method: 'DELETE' }); |
| if (!res.ok) throw new Error('Delete failed'); |
| |
| const tab = document.querySelector(`.doc-tab[data-doc-id="${activeDocId}"]`); |
| if (tab) tab.remove(); |
| docs.delete(activeDocId); |
| |
| const remaining = Array.from(docs.keys()); |
| if (remaining.length > 0) { |
| switchToDoc(remaining[0]); |
| } else { |
| activeDocId = null; |
| closePanel(); |
| } |
| if (uiModule) uiModule.showToast('Document deleted'); |
| } catch (e) { |
| console.error('Failed to delete document:', e); |
| if (uiModule) uiModule.showError('Failed to delete document'); |
| } |
| } |
|
|
| |
| function toggleFullscreen() { |
| const pane = document.getElementById('doc-editor-pane'); |
| const container = document.getElementById('chat-container'); |
| if (!pane) return; |
| |
| |
| |
| |
| |
| if (pane.classList.contains('doc-fullscreen')) { |
| pane.classList.remove('doc-fullscreen'); |
| if (container) container.style.display = ''; |
| } else { |
| pane.classList.add('doc-fullscreen'); |
| if (container) container.style.display = 'none'; |
| } |
| |
| const mdToolbar = document.getElementById('doc-md-toolbar'); |
| if (mdToolbar?._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); |
| } |
|
|
| |
| function _setMarkdownPreviewActive(active, { remember = true } = {}) { |
| const preview = document.getElementById('doc-md-preview'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!preview || !wrap || !textarea) return; |
|
|
| if (active) { |
| const md = textarea.value || ''; |
| if (markdownModule && markdownModule.mdToHtml) { |
| preview.innerHTML = markdownModule.mdToHtml(md); |
| } else { |
| preview.innerHTML = md.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g, '<br>'); |
| } |
| if (window.hljs) { |
| preview.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b)); |
| } |
| preview.style.display = ''; |
| wrap.style.display = 'none'; |
| } else { |
| preview.style.display = 'none'; |
| preview.innerHTML = ''; |
| const isEmailDoc = docs.get(activeDocId)?.language === 'email'; |
| const richEmailBody = document.getElementById('doc-email-richbody'); |
| if (!(isEmailDoc && richEmailBody && richEmailBody.style.display !== 'none')) { |
| wrap.style.display = ''; |
| } |
| } |
| if (remember && activeDocId && docs.has(activeDocId)) { |
| docs.get(activeDocId)._markdownPreviewActive = !!active; |
| } |
| _syncHeaderActions(); |
| } |
|
|
| function toggleMarkdownPreview() { |
| const preview = document.getElementById('doc-md-preview'); |
| _setMarkdownPreviewActive(!(preview && preview.style.display !== 'none')); |
| } |
|
|
| |
| function parseCSV(text) { |
| const rows = []; |
| let row = []; |
| let field = ''; |
| let inQuotes = false; |
| for (let i = 0; i < text.length; i++) { |
| const ch = text[i]; |
| if (inQuotes) { |
| if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; } |
| else if (ch === '"') { inQuotes = false; } |
| else { field += ch; } |
| } else { |
| if (ch === '"') { inQuotes = true; } |
| else if (ch === ',') { row.push(field); field = ''; } |
| else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) { |
| if (ch === '\r') i++; |
| row.push(field); field = ''; |
| if (row.some(c => c.trim())) rows.push(row); |
| row = []; |
| } else { field += ch; } |
| } |
| } |
| row.push(field); |
| if (row.some(c => c.trim())) rows.push(row); |
| return rows; |
| } |
|
|
| |
| function csvEscapeField(val) { |
| if (val.includes(',') || val.includes('"') || val.includes('\n')) { |
| return '"' + val.replace(/"/g, '""') + '"'; |
| } |
| return val; |
| } |
|
|
| |
| function syncTableToTextarea(preview, textarea) { |
| const table = preview.querySelector('.csv-table'); |
| if (!table) return; |
| const lines = []; |
| |
| const ths = table.querySelectorAll('thead th'); |
| if (ths.length) lines.push([...ths].map(th => csvEscapeField(th.textContent)).join(',')); |
| |
| table.querySelectorAll('tbody tr').forEach(tr => { |
| const cells = [...tr.querySelectorAll('td')].map(td => csvEscapeField(td.textContent)); |
| lines.push(cells.join(',')); |
| }); |
| textarea.value = lines.join('\n') + '\n'; |
| textarea.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
|
|
| |
| function toggleCsvPreview() { |
| const preview = document.getElementById('doc-csv-preview'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!preview || !wrap || !textarea) return; |
|
|
| if (preview.style.display === 'none') { |
| const rows = parseCSV(textarea.value || ''); |
| if (rows.length === 0) { |
| |
| |
| |
| let outputPanel = document.getElementById('doc-run-output'); |
| if (!outputPanel) { |
| outputPanel = document.createElement('div'); |
| outputPanel.id = 'doc-run-output'; |
| outputPanel.className = 'doc-run-output'; |
| const editorWrap = document.getElementById('doc-editor-wrap'); |
| if (editorWrap) editorWrap.after(outputPanel); |
| } |
| outputPanel.style.display = 'block'; |
| outputPanel.innerHTML = '<pre class="doc-run-error">No data — CSV is empty or unparseable.</pre>'; |
| return; |
| } else { |
| const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| const colCount = Math.max(...rows.map(r => r.length)); |
| let html = '<div class="csv-table-wrap"><table class="csv-table"><thead><tr>'; |
| for (let j = 0; j < colCount; j++) { |
| html += `<th contenteditable="true">${esc(rows[0][j] || '')}</th>`; |
| } |
| html += '</tr></thead><tbody>'; |
| for (let i = 1; i < rows.length; i++) { |
| html += '<tr>'; |
| for (let j = 0; j < colCount; j++) { |
| html += `<td contenteditable="true">${esc(rows[i][j] || '')}</td>`; |
| } |
| html += '</tr>'; |
| } |
| html += '</tbody></table>'; |
| html += '</div>'; |
| preview.innerHTML = html; |
|
|
| |
| const table = preview.querySelector('.csv-table'); |
| if (table) { |
| table.addEventListener('input', () => syncTableToTextarea(preview, textarea)); |
| |
| table.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| |
| const cell = e.target.closest('td,th'); |
| if (!cell) return; |
| const colIdx = [...cell.parentElement.children].indexOf(cell); |
| const nextRow = cell.parentElement.nextElementSibling; |
| if (nextRow && nextRow.children[colIdx]) { |
| nextRow.children[colIdx].focus(); |
| } |
| } else if (e.key === 'Tab') { |
| e.preventDefault(); |
| const cell = e.target.closest('td,th'); |
| if (!cell) return; |
| const next = e.shiftKey ? cell.previousElementSibling : cell.nextElementSibling; |
| if (next) next.focus(); |
| } |
| }); |
| } |
|
|
| |
| const addBtn = preview.querySelector('.csv-add-row-btn'); |
| if (addBtn && table) { |
| addBtn.addEventListener('click', () => { |
| const tbody = table.querySelector('tbody'); |
| const tr = document.createElement('tr'); |
| for (let j = 0; j < colCount; j++) { |
| const td = document.createElement('td'); |
| td.contentEditable = 'true'; |
| tr.appendChild(td); |
| } |
| tbody.appendChild(tr); |
| tr.children[0].focus(); |
| syncTableToTextarea(preview, textarea); |
| }); |
| } |
| } |
| preview.style.display = ''; |
| wrap.style.display = 'none'; |
| } else { |
| preview.style.display = 'none'; |
| wrap.style.display = ''; |
| } |
| |
| |
| |
| |
| _syncHeaderActions(); |
| } |
|
|
| |
| function toggleHtmlPreview() { |
| const iframe = document.getElementById('doc-html-preview'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (!iframe || !wrap || !textarea) return; |
|
|
| if (!_htmlPreviewActive) { |
| |
| const mdPreview = document.getElementById('doc-md-preview'); |
| if (mdPreview) mdPreview.style.display = 'none'; |
| const code = textarea.value || ''; |
| iframe.srcdoc = code; |
| iframe.style.display = ''; |
| wrap.style.display = 'none'; |
| _htmlPreviewActive = true; |
| renderTabs(); |
| } else { |
| exitHtmlPreview(); |
| } |
| } |
|
|
| |
| function exitHtmlPreview() { |
| const iframe = document.getElementById('doc-html-preview'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (!_htmlPreviewActive) return; |
| _htmlPreviewActive = false; |
| if (iframe) { iframe.style.display = 'none'; iframe.srcdoc = ''; } |
| if (wrap) wrap.style.display = ''; |
| renderTabs(); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| function simpleDiff(oldText, newText) { |
| let i = 0; |
| const minLen = Math.min(oldText.length, newText.length); |
| while (i < minLen && oldText[i] === newText[i]) i++; |
| const prefixLen = i; |
|
|
| let oj = oldText.length; |
| let nj = newText.length; |
| while (oj > prefixLen && nj > prefixLen && oldText[oj - 1] === newText[nj - 1]) { |
| oj--; nj--; |
| } |
|
|
| return { |
| prefixLen, |
| oldMid: oldText.slice(prefixLen, oj), |
| newMid: newText.slice(prefixLen, nj), |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function lineDiff(oldText, newText) { |
| const oldLines = oldText.split('\n'); |
| const newLines = newText.split('\n'); |
|
|
| |
| const m = oldLines.length, n = newLines.length; |
| |
| if (m * n > 500000) return null; |
|
|
| const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1)); |
| for (let i = m - 1; i >= 0; i--) { |
| for (let j = n - 1; j >= 0; j--) { |
| if (oldLines[i] === newLines[j]) { |
| dp[i][j] = dp[i + 1][j + 1] + 1; |
| } else { |
| dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); |
| } |
| } |
| } |
|
|
| const result = []; |
| let i = 0, j = 0; |
| while (i < m || j < n) { |
| if (i < m && j < n && oldLines[i] === newLines[j]) { |
| result.push({ type: 'same', text: oldLines[i] }); |
| i++; j++; |
| } else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) { |
| result.push({ type: 'add', text: newLines[j] }); |
| j++; |
| } else { |
| result.push({ type: 'del', text: oldLines[i] }); |
| i++; |
| } |
| } |
| return result; |
| } |
|
|
| async function animateDocChange(oldText, newText) { |
| if (_animationCancel) _animationCancel(); |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (!textarea) return false; |
| if (oldText === newText) return true; |
|
|
| const diff = lineDiff(oldText, newText); |
| if (!diff) return false; |
|
|
| |
| const delCount = diff.filter(d => d.type === 'del').length; |
| const addCount = diff.filter(d => d.type === 'add').length; |
| if (delCount + addCount === 0) return true; |
|
|
| _animationInProgress = true; |
| let cancelled = false; |
| _animationCancel = () => { cancelled = true; }; |
|
|
| textarea.readOnly = true; |
| if (wrap) wrap.classList.add('animating'); |
|
|
| try { |
| |
| const overlay = document.createElement('div'); |
| overlay.className = 'doc-diff-overlay'; |
|
|
| |
| const stats = document.createElement('div'); |
| stats.className = 'doc-diff-stats'; |
| stats.innerHTML = `<span class="diff-stat-del">\u2212${delCount}</span> <span class="diff-stat-add">+${addCount}</span>`; |
| overlay.appendChild(stats); |
|
|
| const content = document.createElement('div'); |
| content.className = 'doc-diff-content'; |
|
|
| |
| let inContext = false; |
| let skipped = 0; |
| diff.forEach((line, idx) => { |
| if (line.type === 'same') { |
| |
| const nearChange = diff.slice(Math.max(0, idx - 2), idx + 3).some(d => d.type !== 'same'); |
| if (nearChange) { |
| if (skipped > 0) { |
| const sep = document.createElement('div'); |
| sep.className = 'doc-diff-sep'; |
| sep.textContent = `\u22EF ${skipped} unchanged`; |
| content.appendChild(sep); |
| skipped = 0; |
| } |
| const row = document.createElement('div'); |
| row.className = 'doc-diff-line same'; |
| row.textContent = line.text || '\u00A0'; |
| content.appendChild(row); |
| } else { |
| skipped++; |
| } |
| } else { |
| if (skipped > 0) { |
| const sep = document.createElement('div'); |
| sep.className = 'doc-diff-sep'; |
| sep.textContent = `\u22EF ${skipped} unchanged`; |
| content.appendChild(sep); |
| skipped = 0; |
| } |
| const row = document.createElement('div'); |
| row.className = 'doc-diff-line ' + line.type; |
| row.textContent = (line.type === 'del' ? '\u2212 ' : '+ ') + (line.text || '\u00A0'); |
| content.appendChild(row); |
| } |
| }); |
|
|
| overlay.appendChild(content); |
|
|
| |
| const editorArea = textarea.parentElement; |
| if (editorArea) editorArea.appendChild(overlay); |
|
|
| |
| overlay.offsetHeight; |
| overlay.classList.add('visible'); |
|
|
| const DIFF_DISPLAY_MS = 2500; |
| await new Promise(r => setTimeout(r, cancelled ? 0 : DIFF_DISPLAY_MS)); |
|
|
| if (!cancelled) { |
| overlay.classList.remove('visible'); |
| overlay.classList.add('fading'); |
| textarea.value = newText; |
| syncHighlighting(); |
| await new Promise(r => setTimeout(r, 400)); |
| } |
|
|
| overlay.remove(); |
|
|
| if (!cancelled) { |
| textarea.value = newText; |
| syncHighlighting(); |
| } |
|
|
| return !cancelled; |
| } finally { |
| textarea.readOnly = false; |
| _animationInProgress = false; |
| _animationCancel = null; |
| if (wrap) wrap.classList.remove('animating'); |
| } |
| } |
|
|
| |
| let _streamDocId = null; |
|
|
| |
| |
| function _syncStreamDocChrome(doc) { |
| if (!doc) return; |
| const lang = (doc.language || 'markdown').toLowerCase(); |
| const isMd = lang === 'markdown'; |
| const isPdf = _isFormBackedDoc(doc.content || ''); |
| |
| |
| |
| |
| const renderable = ['svg', 'html', 'css', 'csv', 'python', 'javascript', 'typescript', |
| 'json', 'xml', 'bash', 'sh', 'yaml', 'toml', 'sql']; |
| const isCodeRenderable = renderable.includes(lang); |
| const mt = document.getElementById('doc-md-toolbar'); |
| if (mt) { |
| const showToolbar = isMd || isPdf || isCodeRenderable; |
| mt.style.display = showToolbar ? '' : 'none'; |
| mt.dataset.mode = isMd ? 'md' : (isPdf ? 'pdf' : (isCodeRenderable ? 'code' : '')); |
| if (showToolbar && mt._syncOverflow) requestAnimationFrame(mt._syncOverflow); |
| } |
| _syncHeaderActions(); |
| } |
|
|
| |
| export function streamDocOpen(title, language) { |
| |
| if (_streamDocId && docs.has(_streamDocId)) { |
| const existing = docs.get(_streamDocId); |
| if (title) existing.title = title; |
| if (language) existing.language = language; |
| |
| const titleInput = document.getElementById('doc-title-input'); |
| const langSelect = document.getElementById('doc-language-select'); |
| if (title && titleInput) titleInput.value = title; |
| if (langSelect) langSelect.value = existing.language || 'markdown'; |
| if (language === 'email') { |
| _showEmailFields(existing); |
| } |
| _syncStreamDocChrome(existing); |
| renderTabs(); |
| return; |
| } |
|
|
| const sessionId = sessionModule?.getCurrentSessionId() || ''; |
| |
| let docId = null; |
| if (title) { |
| for (const [existingId, existingDoc] of docs) { |
| if (existingDoc.title === title && existingDoc.sessionId === sessionId) { |
| docId = existingId; |
| break; |
| } |
| } |
| } |
| if (!docId) { |
| docId = '_streaming_' + Date.now(); |
| docs.set(docId, { |
| id: docId, |
| title: title || '', |
| language: language || '', |
| content: '', |
| version: 1, |
| sessionId, |
| }); |
| } |
| _streamDocId = docId; |
| activeDocId = docId; |
| _syncDocIndicator(); |
|
|
| if (!isOpen) openPanel(); |
|
|
| |
| const toggleBtn = document.getElementById('overflow-doc-btn'); |
| if (toggleBtn) { |
| toggleBtn.style.display = ''; |
| toggleBtn.classList.remove('toolbar-collapsed'); |
| toggleBtn.classList.add('has-docs'); |
| } |
| const docInd2 = document.getElementById('doc-indicator-btn'); |
| if (docInd2) docInd2.classList.add('visible'); |
|
|
| const titleInput = document.getElementById('doc-title-input'); |
| const langSelect = document.getElementById('doc-language-select'); |
| const badge = document.getElementById('doc-version-badge'); |
| if (titleInput) titleInput.value = title || ''; |
| if (langSelect) langSelect.value = language || 'markdown'; |
| if (badge) badge.textContent = 'v1'; |
|
|
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) { |
| textarea.disabled = false; |
| textarea.placeholder = 'Document content...'; |
| textarea.value = ''; |
| } |
| |
| const indicator = document.getElementById('doc-stream-indicator'); |
| if (indicator) indicator.style.display = ''; |
|
|
| |
| |
| if (language === 'email') { |
| const streamDoc = docs.get(_streamDocId); |
| if (streamDoc) _showEmailFields(streamDoc); |
| } else { |
| _hideEmailFields(); |
| } |
|
|
| syncHighlighting(); |
| _syncStreamDocChrome(docs.get(_streamDocId)); |
| renderTabs(); |
| } |
|
|
| |
| let _editAnimFrame = null; |
| function _animateDocEdit(textarea, newContent) { |
| if (_editAnimFrame) cancelAnimationFrame(_editAnimFrame); |
| const indicator = document.getElementById('doc-stream-indicator'); |
| if (indicator) indicator.style.display = ''; |
| const codeEl = document.getElementById('doc-editor-code'); |
| let cursor = document.getElementById('doc-stream-cursor'); |
| if (!cursor) { |
| cursor = document.createElement('span'); |
| cursor.id = 'doc-stream-cursor'; |
| cursor.className = 'doc-stream-cursor'; |
| cursor.textContent = '\u258F'; |
| } |
|
|
| const oldContent = textarea.value; |
|
|
| |
| let prefixLen = 0; |
| while (prefixLen < oldContent.length && prefixLen < newContent.length && |
| oldContent[prefixLen] === newContent[prefixLen]) prefixLen++; |
| let suffixLen = 0; |
| while (suffixLen < (oldContent.length - prefixLen) && |
| suffixLen < (newContent.length - prefixLen) && |
| oldContent[oldContent.length - 1 - suffixLen] === newContent[newContent.length - 1 - suffixLen]) suffixLen++; |
|
|
| const deletedText = oldContent.slice(prefixLen, oldContent.length - suffixLen); |
| const insertedText = newContent.slice(prefixLen, newContent.length - suffixLen); |
| const suffix = oldContent.slice(oldContent.length - suffixLen); |
|
|
| |
| const deleteChunk = Math.max(2, Math.ceil(deletedText.length / 30)); |
| const insertChunk = Math.max(2, Math.ceil(insertedText.length / 30)); |
| let deletePos = deletedText.length; |
| let insertPos = 0; |
| let phase = deletedText.length > 0 ? 'delete' : 'insert'; |
|
|
| |
| const linesBefore = oldContent.slice(0, prefixLen).split('\n').length; |
| const lineH = parseFloat(getComputedStyle(textarea).lineHeight) || 20; |
| textarea.scrollTop = Math.max(0, (linesBefore - 3) * lineH); |
|
|
| function tick() { |
| if (phase === 'delete') { |
| deletePos = Math.max(0, deletePos - deleteChunk); |
| const current = oldContent.slice(0, prefixLen) + deletedText.slice(0, deletePos) + suffix; |
| textarea.value = current; |
| if (codeEl) codeEl.textContent = current + '\n'; |
| if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor); |
| updateLineNumbers(current); |
| if (deletePos > 0) { |
| _editAnimFrame = requestAnimationFrame(tick); |
| } else { |
| phase = 'insert'; |
| _editAnimFrame = requestAnimationFrame(tick); |
| } |
| } else { |
| insertPos = Math.min(insertPos + insertChunk, insertedText.length); |
| const current = newContent.slice(0, prefixLen + insertPos) + suffix; |
| textarea.value = current; |
| if (codeEl) codeEl.textContent = current + '\n'; |
| if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor); |
| updateLineNumbers(current); |
| if (insertPos < insertedText.length) { |
| _editAnimFrame = requestAnimationFrame(tick); |
| } else { |
| |
| textarea.value = newContent; |
| _editAnimFrame = null; |
| if (indicator) indicator.style.display = 'none'; |
| if (cursor) cursor.remove(); |
| syncHighlighting(); |
| } |
| } |
| } |
| _editAnimFrame = requestAnimationFrame(tick); |
| } |
|
|
| |
| let _streamHlDebounce = null; |
| export function streamDocDelta(content) { |
| if (!_streamDocId) return; |
| const doc = docs.get(_streamDocId); |
| if (doc) doc.content = content; |
|
|
| if (_streamDocId === activeDocId) { |
| if ((doc?.language || '').toLowerCase() === 'email') { |
| _showEmailFields(doc); |
| return; |
| } |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) { |
| textarea.value = content; |
| |
| textarea.scrollTop = textarea.scrollHeight; |
| } |
| |
| const codeEl = document.getElementById('doc-editor-code'); |
| if (codeEl) codeEl.textContent = content + '\n'; |
| updateLineNumbers(content); |
| |
| let cursor = document.getElementById('doc-stream-cursor'); |
| if (!cursor) { |
| cursor = document.createElement('span'); |
| cursor.id = 'doc-stream-cursor'; |
| cursor.className = 'doc-stream-cursor'; |
| cursor.textContent = '\u258F'; |
| } |
| if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor); |
| clearTimeout(_streamHlDebounce); |
| _streamHlDebounce = setTimeout(syncHighlighting, 150); |
| } |
| } |
|
|
| |
| |
| export function streamDocFinalize() { |
| const oldId = _streamDocId; |
| _streamDocId = null; |
| |
| const indicator = document.getElementById('doc-stream-indicator'); |
| if (indicator) indicator.style.display = 'none'; |
| const cursor = document.getElementById('doc-stream-cursor'); |
| if (cursor) cursor.remove(); |
| |
| clearTimeout(_streamHlDebounce); |
| syncHighlighting(); |
| attemptAutoDetect(); |
| return oldId; |
| } |
|
|
| |
| export function handleDocUpdate(data) { |
| const streamingId = streamDocFinalize(); |
| let docId = data.doc_id; |
| const newContent = data.content || ''; |
|
|
| |
| if (streamingId && streamingId.startsWith('_streaming_') && docs.has(streamingId)) { |
| const tempDoc = docs.get(streamingId); |
| docs.delete(streamingId); |
| tempDoc.id = docId; |
| tempDoc.version = data.version || 1; |
| if (data.title) tempDoc.title = data.title; |
| if (data.language) tempDoc.language = data.language; |
| tempDoc.content = newContent; |
| docs.set(docId, tempDoc); |
| |
| if (activeDocId === streamingId) activeDocId = docId; |
| } |
|
|
| |
| if (!docs.has(docId)) { |
| const curSession = sessionModule?.getCurrentSessionId() || ''; |
| let reuseId = null; |
|
|
| |
| if (data.title) { |
| for (const [existingId, existingDoc] of docs) { |
| if (existingDoc.title === data.title && existingDoc.sessionId === curSession) { |
| reuseId = existingId; |
| break; |
| } |
| } |
| } |
|
|
| |
| if (!reuseId) { |
| for (const [existingId, existingDoc] of docs) { |
| if (existingDoc.sessionId === curSession && |
| (!existingDoc.title || existingDoc.title === 'Untitled') && |
| (!existingDoc.content || existingDoc.content.trim() === '')) { |
| reuseId = existingId; |
| break; |
| } |
| } |
| } |
|
|
| if (reuseId) docId = reuseId; |
| } |
|
|
| |
| const textarea = document.getElementById('doc-editor-textarea'); |
| const oldContent = (docId === activeDocId && textarea) ? textarea.value : ''; |
| const isExistingDoc = docs.has(docId); |
|
|
| |
| if (isExistingDoc) { |
| const doc = docs.get(docId); |
| doc.content = newContent; |
| doc.version = data.version || doc.version; |
| if (data.title) doc.title = data.title; |
| if (data.language) doc.language = data.language; |
| } else { |
| docs.set(docId, { |
| id: docId, |
| title: data.title || '', |
| language: data.language || '', |
| content: newContent, |
| version: data.version || 1, |
| sessionId: sessionModule?.getCurrentSessionId() || '', |
| }); |
| } |
|
|
| _syncDocIndicator(); |
|
|
| |
| if (!data.title) autoTitleFromContent(newContent, docId); |
|
|
| if (!isOpen) openPanel(); |
|
|
| |
| const toggleBtn = document.getElementById('overflow-doc-btn'); |
| if (toggleBtn) { |
| toggleBtn.style.display = ''; |
| toggleBtn.classList.remove('toolbar-collapsed'); |
| toggleBtn.classList.add('has-docs'); |
| } |
| const docInd = document.getElementById('doc-indicator-btn'); |
| if (docInd) docInd.classList.add('visible'); |
|
|
| |
| activeDocId = docId; |
|
|
| const badge = document.getElementById('doc-version-badge'); |
| const titleInput = document.getElementById('doc-title-input'); |
| const langSelect = document.getElementById('doc-language-select'); |
|
|
| |
| if (textarea) { |
| textarea.disabled = false; |
| textarea.placeholder = 'Document content...'; |
| } |
| if (badge) badge.textContent = `v${data.version || 1}`; |
| if (data.title && titleInput) titleInput.value = data.title; |
| |
| const docLang = data.language || (docs.has(docId) && docs.get(docId).language) || ''; |
| if (docLang && langSelect) langSelect.value = docLang; |
| if (!docLang) attemptAutoDetect(); |
| const isEmailUpdate = (docLang || '').toLowerCase() === 'email'; |
|
|
| |
| const isEdit = !isEmailUpdate && isExistingDoc && oldContent && oldContent !== newContent && !streamingId; |
| if (isEdit && textarea) { |
| |
| const oldLines = oldContent.split('\n'); |
| const newLines = newContent.split('\n'); |
| let changedLines = 0; |
| const maxLen = Math.max(oldLines.length, newLines.length); |
| for (let li = 0; li < maxLen; li++) { |
| if (oldLines[li] !== newLines[li]) changedLines++; |
| } |
| if (changedLines >= DIFF_MODE_THRESHOLD) { |
| enterDiffMode(oldContent, newContent); |
| } else { |
| _animateDocEdit(textarea, newContent); |
| } |
| } else { |
| if (isEmailUpdate) { |
| const updatedDocForEmail = docs.get(docId); |
| if (updatedDocForEmail) { |
| _setMarkdownPreviewActive(false, { remember: false }); |
| _showEmailFields(updatedDocForEmail); |
| } |
| } else { |
| if (textarea) textarea.value = newContent; |
| syncHighlighting(); |
| } |
| } |
|
|
| |
| const wrap = document.getElementById('doc-editor-wrap'); |
| if (wrap && !isEdit) { |
| wrap.classList.remove('doc-updated-flash'); |
| void wrap.offsetWidth; |
| wrap.classList.add('doc-updated-flash'); |
| wrap.addEventListener('animationend', () => wrap.classList.remove('doc-updated-flash'), { once: true }); |
| } |
|
|
| |
| const updatedDoc = docs.get(docId); |
| if (isEmailUpdate && updatedDoc) { |
| updatedDoc.language = 'email'; |
| if (langSelect) langSelect.value = 'email'; |
| _showEmailFields(updatedDoc); |
| } |
| if (updatedDoc && !updatedDoc.userSetLanguage && !updatedDoc.language) { |
| setTimeout(attemptAutoDetect, 100); |
| } |
|
|
| |
| const finalLang = docLang || (updatedDoc && updatedDoc.language) || ''; |
| const mdToolbar = document.getElementById('doc-md-toolbar'); |
| |
| if (mdToolbar) mdToolbar.style.display = ''; |
| |
| if (finalLang === 'csv') { |
| requestAnimationFrame(() => { |
| const csvPreview = document.getElementById('doc-csv-preview'); |
| if (csvPreview && csvPreview.style.display === 'none') toggleCsvPreview(); |
| }); |
| } |
|
|
| renderTabs(); |
|
|
| |
| |
| |
| if (docId === activeDocId) { |
| _syncHeaderActions(); |
| |
| if (_isFormBackedDoc(newContent)) { |
| const explicit = _pdfViewState.get(docId); |
| if (explicit !== false) _refreshPdfPreviewIframe(); |
| } |
| } |
| } |
|
|
| |
| let _versionClickOutside = null; |
| let _versionSavedContent = null; |
| async function toggleVersionHistory() { |
| const panel = document.getElementById('doc-version-panel'); |
| if (!panel || !activeDocId) return; |
|
|
| if (panel.classList.contains('hidden')) { |
| |
| const ta = document.getElementById('doc-editor-textarea'); |
| _versionSavedContent = ta ? ta.value : null; |
|
|
| |
| const sidebar = document.getElementById('sidebar'); |
| const isMobile = window.innerWidth <= 768; |
| if (!isMobile && sidebar) { |
| const sidebarRight = sidebar.classList.contains('right-side'); |
| const collapsed = document.body.classList.contains('sidebar-collapsed'); |
| if (sidebarRight || collapsed) { |
| panel.style.left = '0'; |
| panel.style.right = 'auto'; |
| } else { |
| panel.style.left = sidebar.offsetWidth + 'px'; |
| panel.style.right = 'auto'; |
| } |
| } else if (isMobile) { |
| |
| |
| panel.style.left = ''; |
| panel.style.right = ''; |
| panel.style.top = ''; |
| } |
|
|
| |
| if (panel.parentElement !== document.body) { |
| document.body.appendChild(panel); |
| } |
|
|
| panel.classList.remove('hidden'); |
| await loadVersionHistory(); |
| |
| setTimeout(() => { |
| _versionClickOutside = (e) => { |
| if (!panel.contains(e.target) && e.target.id !== 'doc-version-badge') { |
| _closeVersionPanel(); |
| } |
| }; |
| document.addEventListener('click', _versionClickOutside, true); |
| }, 0); |
| } else { |
| _closeVersionPanel(); |
| } |
| } |
|
|
| function _closeVersionPanel() { |
| const panel = document.getElementById('doc-version-panel'); |
| if (panel) panel.classList.add('hidden'); |
| |
| if (_versionSavedContent !== null) { |
| const ta = document.getElementById('doc-editor-textarea'); |
| if (ta) ta.value = _versionSavedContent; |
| syncHighlighting(); |
| _versionSavedContent = null; |
| } |
| if (_versionClickOutside) { |
| document.removeEventListener('click', _versionClickOutside, true); |
| _versionClickOutside = null; |
| } |
| } |
|
|
| |
| function _buildDiffSummary(oldText, newText) { |
| if (!oldText && !newText) return ''; |
| const oldLines = (oldText || '').split('\n'); |
| const newLines = (newText || '').split('\n'); |
| const added = [], removed = []; |
| |
| const maxCheck = Math.max(oldLines.length, newLines.length); |
| for (let i = 0; i < maxCheck; i++) { |
| const ol = oldLines[i], nl = newLines[i]; |
| if (ol === nl) continue; |
| if (ol !== undefined && (nl === undefined || ol !== nl)) removed.push(ol.trim()); |
| if (nl !== undefined && (ol === undefined || ol !== nl)) added.push(nl.trim()); |
| } |
| |
| const parts = []; |
| for (const line of removed.slice(0, 2)) { |
| if (line) parts.push(`<span class="diff-del">${_escHtml(line.slice(0, 60))}</span>`); |
| } |
| for (const line of added.slice(0, 2)) { |
| if (line) parts.push(`<span class="diff-add">${_escHtml(line.slice(0, 60))}</span>`); |
| } |
| const extra = (added.length + removed.length) - 4; |
| if (extra > 0) parts.push(`<span>+${extra} more changes</span>`); |
| return parts.join('<br>'); |
| } |
| function _escHtml(s) { |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
|
|
| |
| async function loadVersionHistory() { |
| if (!activeDocId) return; |
| const list = document.getElementById('doc-version-list'); |
| if (!list) return; |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}/versions`); |
| const versions = await res.json(); |
|
|
| |
| const diffs = []; |
| for (let i = 0; i < versions.length; i++) { |
| if (i < versions.length - 1) { |
| diffs.push(_buildDiffSummary(versions[i + 1].content, versions[i].content)); |
| } else { |
| diffs.push(''); |
| } |
| } |
|
|
| list.innerHTML = versions.map((v, i) => ` |
| <div class="doc-version-item" data-version="${v.version_number}"> |
| <div class="doc-version-info"> |
| <span class="doc-version-num">v${v.version_number}</span> |
| ${i === 0 ? '<span class="doc-version-latest">latest</span>' : `<span class="doc-version-source">${v.source}</span><span class="doc-version-time">${v.created_at ? new Date(v.created_at).toLocaleString() : ''}</span>`} |
| </div> |
| ${v.summary ? `<div class="doc-version-summary">${v.summary}</div>` : ''} |
| ${diffs[i] ? `<div class="doc-version-diff">${diffs[i]}</div>` : ''} |
| ${i > 0 ? `<button class="doc-version-restore" data-version="${v.version_number}">Restore</button>` : ''} |
| </div> |
| `).join(''); |
|
|
| |
| list.querySelectorAll('.doc-version-restore').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| restoreVersion(parseInt(btn.dataset.version)); |
| }); |
| }); |
|
|
| |
| list.querySelectorAll('.doc-version-item').forEach(item => { |
| item.addEventListener('click', (e) => { |
| if (e.target.classList.contains('doc-version-restore')) return; |
| |
| list.querySelectorAll('.doc-version-item.active').forEach(el => el.classList.remove('active')); |
| item.classList.add('active'); |
| previewVersion(parseInt(item.dataset.version)); |
| }); |
| }); |
| } catch (e) { |
| list.innerHTML = '<div style="padding:8px;opacity:0.5;">Failed to load versions</div>'; |
| } |
| } |
|
|
| |
| async function previewVersion(num) { |
| if (!activeDocId) return; |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}/version/${num}`); |
| const ver = await res.json(); |
| const textarea = document.getElementById('doc-editor-textarea'); |
| if (textarea) textarea.value = ver.content || ''; |
| syncHighlighting(); |
| } catch (e) { |
| console.error('Failed to preview version:', e); |
| } |
| } |
|
|
| |
| async function restoreVersion(num) { |
| if (!activeDocId) return; |
| try { |
| const res = await fetch(`${API_BASE}/api/document/${activeDocId}/restore/${num}`, { |
| method: 'POST', |
| }); |
| const doc = await res.json(); |
| populateEditor(doc); |
| |
| _versionSavedContent = null; |
| |
| if (docs.has(activeDocId)) { |
| const d = docs.get(activeDocId); |
| d.content = doc.current_content || ''; |
| d.version = doc.version_count || 1; |
| } |
| await loadVersionHistory(); |
| if (uiModule) uiModule.showToast(`Restored to v${num}`); |
| } catch (e) { |
| console.error('Failed to restore version:', e); |
| if (uiModule) uiModule.showError('Failed to restore version'); |
| } |
| } |
|
|
| |
| async function updateTitle(overrideDocId, overrideTitle) { |
| const docId = overrideDocId || activeDocId; |
| if (!docId) return; |
| const title = overrideTitle || document.getElementById('doc-title-input')?.value; |
| if (!title) return; |
| try { |
| await fetch(`${API_BASE}/api/document/${docId}`, { |
| method: 'PATCH', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ title }), |
| }); |
| if (docs.has(docId)) { |
| docs.get(docId).title = title; |
| renderTabs(); |
| } |
| } catch (e) { |
| console.error('Failed to update title:', e); |
| } |
| } |
|
|
| |
| function autoTitleFromContent(content, docId) { |
| const id = docId || activeDocId; |
| if (!id) return; |
| const doc = docs.get(id); |
| if (!doc || (doc.title && doc.title !== '' && doc.title !== 'Untitled')) return; |
|
|
| const text = (content || '').trimStart(); |
| if (!text) return; |
|
|
| let title = null; |
|
|
| |
| const mdMatch = text.match(/^#{1,3}\s+(.+)/m); |
| if (mdMatch) { |
| title = mdMatch[1].trim(); |
| } |
|
|
| |
| if (!title) { |
| const htmlMatch = text.match(/<h[1-3][^>]*>([^<]+)<\/h[1-3]>/i); |
| if (htmlMatch) title = htmlMatch[1].trim(); |
| } |
|
|
| |
| if (!title) { |
| const firstLine = text.split('\n').find(l => l.trim().length > 0); |
| if (firstLine) { |
| const cleaned = firstLine.trim(); |
| if (cleaned.length <= 60 && cleaned.length >= 2) { |
| title = cleaned; |
| } |
| } |
| } |
|
|
| if (!title) return; |
|
|
| |
| title = title.replace(/[:#*`]+$/g, '').trim(); |
| if (title.length > 50) title = title.slice(0, 48) + '...'; |
| if (!title) return; |
|
|
| updateTitle(id, title); |
| const titleInput = document.getElementById('doc-title-input'); |
| if (titleInput && id === activeDocId) titleInput.value = title; |
| } |
|
|
| |
| async function updateLanguage() { |
| if (!activeDocId) return; |
| const select = document.getElementById('doc-language-select'); |
| if (!select) return; |
| try { |
| await fetch(`${API_BASE}/api/document/${activeDocId}`, { |
| method: 'PATCH', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ language: select.value }), |
| }); |
| if (docs.has(activeDocId)) { |
| docs.get(activeDocId).language = select.value; |
| renderTabs(); |
| } |
| } catch (e) { |
| console.error('Failed to update language:', e); |
| } |
| } |
|
|
| |
| export function clearAll() { |
| docs.clear(); |
| activeDocId = null; |
| _lastSessionId = ''; |
| if (isOpen) closePanel(); |
| _syncDocIndicator(); |
| } |
|
|
| export function isPanelOpen() { |
| return isOpen; |
| } |
|
|
| export function getCurrentDocId() { |
| return activeDocId; |
| } |
|
|
| |
| export function findEmailDocId(uid, folder) { |
| if (uid == null) return null; |
| const wantUid = String(uid); |
| const wantFolder = (folder || '').trim(); |
| for (const [id, d] of docs) { |
| if (d.language !== 'email') continue; |
| const fields = _parseEmailHeader(d.content || ''); |
| if (fields.sourceUid && String(fields.sourceUid) === wantUid && |
| (!wantFolder || (fields.sourceFolder || '').trim() === wantFolder)) { |
| return id; |
| } |
| } |
| return null; |
| } |
|
|
|
|
|
|
| const documentModule = { |
| init, |
| openPanel, |
| closePanel, |
| swapSide, |
| createDocument, |
| newDocument, |
| loadDocument, |
| injectFreshDoc, |
| ensurePaneMounted: _ensureDocPaneMounted, |
| loadSessionDocs, |
| ensureDocPanel, |
| saveDocument, |
| handleDocUpdate, |
| handleDocSuggestions, |
| streamDocOpen, |
| streamDocDelta, |
| streamDocFinalize, |
| isPanelOpen, |
| enterDiffMode, |
| exitDiffMode, |
| isDiffModeActive, |
| getCurrentDocId, |
| findEmailDocId, |
| getSelectionContext, |
| clearSelection, |
| clearAll, |
| openLibrary, |
| closeLibrary, |
| isLibraryOpen, |
| }; |
|
|
| export default documentModule; |
| window.documentModule = documentModule; |
|
|