1// static/js/document.js 2/** 3 * Document editor module â multi-document tabbed panel alongside chat. 4 * Supports multiple open documents with tab switching, per-doc state, 5 * and theme-aware styling. 6 */ 7 8 9import uiModule from './ui.js'; 10import sessionModule from './sessions.js'; 11import emojiPicker from './emojiPicker.js'; 12import markdownModule from './markdown.js'; 13import codeRunnerModule from './codeRunner.js'; 14import { langIcon } from './langIcons.js'; 15import spinnerModule from './spinner.js'; 16import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js'; 17import signatureModule from './signature.js'; 18import * as Modals from './modalManager.js'; 19 20 let API_BASE = ''; 21 let isOpen = false; 22 let _hlDebounce = null; 23 let _isEditingTabTitle = false; 24 let _autoDetectDebounce = null; 25 let _autoTitleDebounce = null; 26 let _autoSaveDebounce = null; 27 let _animationInProgress = false; 28 let _animationCancel = null; // function to cancel current animation 29 let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing 30 let _emailAccountsCache = null; 31 let _emailAccountsCacheAt = 0; 32 let _emailHeaderManualExpandUntil = 0; 33 34 // Diff mode state 35 let _diffModeActive = false; 36 let _diffOldContent = null; 37 let _diffNewContent = null; 38 let _diffChunks = []; // [{id, oldLines, newLines, startLine, resolved, accepted}] 39 let _diffUnresolvedCount = 0; 40 41 // Language auto-detection config 42 const AUTO_DETECT_DELAY = 500; 43 const AUTO_DETECT_MIN_CHARS = 30; 44 const AUTO_DETECT_MIN_RELEVANCE = 8; 45 const AUTO_DETECT_SAMPLE_SIZE = 2000; 46 const HLJS_TO_DROPDOWN = { 47 python: 'python', javascript: 'javascript', typescript: 'typescript', 48 xml: 'html', html: 'html', css: 'css', markdown: 'markdown', 49 json: 'json', yaml: 'yaml', bash: 'bash', shell: 'bash', 50 sql: 'sql', rust: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', 51 csv: 'csv', 52 }; 53 54 // Languages rendered in the sandboxed preview iframe. SVG and XML markup 55 // render as inline content in an HTML document, so they share the HTML 56 // "Run / Preview" path. (hljs maps detected `xml` â `html` already; this also 57 // covers the doc being explicitly typed svg/xml.) 58 const _isRenderLang = (l) => ['html', 'svg', 'xml'].includes((l || '').toLowerCase()); 59 // Languages that get the segmented Code / Run-or-View toggle in the toolbar 60 // (the same UX as markdown's Edit / Preview switch). CSV's "run" view is the 61 // table; Python/JS/etc.'s is the code-run output; HTML/SVG/XML render via 62 // the iframe preview. 63 const _hasViewToggle = (l) => { 64 const lang = (l || '').toLowerCase(); 65 return [ 66 'csv', 'python', 'javascript', 'typescript', 'bash', 'sh', 'shell', 67 'php', 'ruby', 'sql', 'java', 'go', 'rust', 68 'c', 'cpp', 'c++', 'csharp', 'c#', 69 'yaml', 'json', 'css', 70 'ini', 'toml', 71 ].includes(lang) || _isRenderLang(lang); 72 }; 73 74 async function _getEmailAccountsCached() { 75 const now = Date.now(); 76 if (_emailAccountsCache && (now - _emailAccountsCacheAt) < 30000) return _emailAccountsCache; 77 try { 78 const res = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' }); 79 if (!res.ok) throw new Error('accounts failed'); 80 const data = await res.json(); 81 _emailAccountsCache = Array.isArray(data.accounts) ? data.accounts : []; 82 } catch (_) { 83 _emailAccountsCache = []; 84 } 85 _emailAccountsCacheAt = now; 86 return _emailAccountsCache; 87 } 88 89 function _accountCanSend(account) { 90 return !!(account && account.smtp_host && account.smtp_user && account.has_smtp_password); 91 } 92 93 async function _resolveComposeSendAccountId() { 94 const activeAccountId = window.__odysseusActiveEmailAccount || null; 95 if (!activeAccountId) return null; 96 const accounts = await _getEmailAccountsCached(); 97 const activeAccount = accounts.find(a => String(a.id) === String(activeAccountId)); 98 if (!activeAccount || _accountCanSend(activeAccount)) return activeAccountId; 99 if (uiModule) uiModule.showToast('Selected email account is receive-only; using your SMTP account.'); 100 return null; 101 } 102 103 // Inject tab menu styles immediately (must exist before any hover) 104 { 105 const s = document.createElement('style'); 106 s.id = 'doc-tab-menu-styles'; 107 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;
107height: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}`; 108 document.head.appendChild(s); 109 } 110 111 // Multi-document state 112 let activeDocId = null; // currently visible doc 113 let _lastSessionId = ''; // session context for "+" button 114 const docs = new Map(); // docId -> { id, title, language, content, version, sessionId } 115 116 const _docOpenKey = (sessionId) => 'odysseus-doc-open-' + sessionId; 117 const _docMinimizedKey = (sessionId) => 'odysseus-doc-minimized-' + sessionId; 118 119 function _markDocVisibleState(sessionId, state) { 120 if (!sessionId) return; 121 if (state === 'open') { 122 localStorage.setItem(_docOpenKey(sessionId), '1'); 123 localStorage.removeItem(_docMinimizedKey(sessionId)); 124 } else if (state === 'minimized') { 125 localStorage.removeItem(_docOpenKey(sessionId)); 126 localStorage.setItem(_docMinimizedKey(sessionId), '1'); 127 } else { 128 localStorage.removeItem(_docOpenKey(sessionId)); 129 localStorage.removeItem(_docMinimizedKey(sessionId)); 130 } 131 } 132 133 /** Switch chat to agent mode if not already */ 134 function _ensureAgentMode() { 135 const ab = document.getElementById('mode-agent-btn'); 136 const cb = document.getElementById('mode-chat-btn'); 137 if (ab && !ab.classList.contains('active')) { 138 ab.click(); 139 } 140 } 141 142 export function init(apiBase) { 143 API_BASE = apiBase; 144 initLibrary({ 145 apiBase, 146 esc: _esc, 147 getDocs: () => docs, 148 isOpen: () => isOpen, 149 createDocument, 150 loadDocument, 151 switchToDoc, 152 openPanel, 153 addDocToTabs, 154 syncDocIndicator: _syncDocIndicator, 155 }); 156 _maybeOpenDocFromHash(); 157 window.addEventListener('hashchange', _maybeOpenDocFromHash); 158 } 159 160 /** Update overflow-doc-btn accent indicator, toolbar indicator, and session list icon */ 161 function _syncDocIndicator() { 162 const btn = document.getElementById('overflow-doc-btn'); 163 // Has docs = at least one non-empty doc in the map 164 const hasDocs = docs.size > 0; 165 if (btn) btn.classList.toggle('has-docs', hasDocs); 166 // Show/hide the toolbar doc indicator when docs exist 167 const indicator = document.getElementById('doc-indicator-btn'); 168 if (indicator) indicator.classList.toggle('visible', hasDocs); 169 // Hide overflow menu item when indicator is shown outside 170 if (btn) btn.style.display = hasDocs ? 'none' : ''; 171 // Update session list icon 172 const sid = sessionModule?.getCurrentSessionId(); 173 if (sid && sessionModule.setSessionHasDocs) { 174 sessionModule.setSessionHasDocs(sid, hasDocs); 175 } 176 } 177 178 // ---- Tab bar rendering ---- 179 180 function updateArrowVisibility(scrollArea, leftBtn, rightBtn) { 181 const atLeft = scrollArea.scrollLeft <= 0; 182 const atRight = scrollArea.scrollLeft + scrollArea.clientWidth >= scrollArea.scrollWidth - 1; 183 leftBtn.style.display = atLeft ? 'none' : ''; 184 rightBtn.style.display = atRight ? 'none' : ''; 185 // Toggle the edge-mask classes so the fade gradient drops to flat on the 186 // side that has nothing to scroll to. Without this the left/right fade 187 // reads as a permanent shadow even when no arrow is showing. 188 scrollArea.classList.toggle('is-at-left', atLeft); 189 scrollArea.classList.toggle('is-at-right', atRight); 190 } 191 192 // Mobile swipe-to-dismiss for the doc sheet. Mirrors the shared bottom-sheet 193 // gesture in ui.js (finger-following drag, velocity-based dismiss, rubber-band 194 // on up-drag, spring snap-back) so it feels identical to the other windows â 195 // but dismisses through the doc panel's own closePanel() lifecycle. 196 function _wireSwipeDismiss(el) { 197 if (!el) return; 198 const DISMISS_THRESHOLD = 50; // px 199 const VELOCITY_THRESHOLD = 0.3; // px/ms â fast flick dismisses below threshold 200 const RUBBER_RESISTANCE = 0.35; // resistance when dragging up past origin 201 let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; 202 let dragging = false, cancelled = false;
203 const getPane = () => document.getElementById('doc-editor-pane'); 204 let pane = null; 205 206 el.addEventListener('touchstart', (e) => { 207 if (window.innerWidth > 768 || e.touches.length !== 1) return; 208 pane = getPane(); 209 if (!pane) return; 210 const t = e.touches[0]; 211 startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; 212 velocity = 0; dragging = false; cancelled = false; 213 }, { passive: true }); 214 215 el.addEventListener('touchmove', (e) => { 216 if (cancelled || !pane || window.innerWidth > 768) return; 217 const t = e.touches[0]; 218 const dx = Math.abs(t.clientX - startX); 219 const dy = t.clientY - startY; 220 if (!dragging) { 221 if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } // horizontal â tab scroll 222 if (Math.abs(dy) > 8) { 223 dragging = true; 224 // Clear the open animation â its `both` fill-mode otherwise pins 225 // transform and overrides our inline finger-following transform. 226 pane.style.animation = '
226none'; 227 pane.style.transition = 'none'; 228 pane.style.willChange = 'transform'; 229 } else return; 230 } 231 const dt = e.timeStamp - lastT; 232 if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; 233 lastY = t.clientY; lastT = e.timeStamp; 234 e.preventDefault(); 235 pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER_RESISTANCE}px)`; 236 }, { passive: false }); 237 238 const endSwipe = () => { 239 if (!dragging || !pane) { pane = null; return; } 240 const p = pane; pane = null; dragging = false; 241 p.style.willChange = ''; 242 const dy = lastY - startY; 243 const shouldDismiss = dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD); 244 if (shouldDismiss) { 245 closePanel('down'); 246 } else { 247 p.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; 248 p.style.transform = ''; 249 setTimeout(() => { p.style.transition = ''; }, 260); 250 } 251 }; 252 el.addEventListener('touchend', endSwipe, { passive: true }); 253 el.addEventListener('touchcancel', endSwipe, { passive: true }); 254 } 255 256 function renderTabs() { 257 if (_isEditingTabTitle) return; // Don't rebuild while editing a title 258 const tabBar = document.getElementById('doc-tab-bar'); 259 if (!tabBar) return; 260 261 // Build tab HTML with scroll arrows 262 // When doc panel is on right (default), + goes on far left; on left, + goes inside scroll area 263 const paneEl = document.querySelector('.doc-editor-pane'); 264 const isDocLeft = paneEl && paneEl.classList.contains('doc-left'); 265 let html = ''; 266 html += '<button class="doc-tab-arrow doc-tab-arrow-left" id="doc-tab-left" title="Scroll left">‹</button>'; 267 html += '<div class="doc-tab-scroll" id="doc-tab-scroll">'; 268 const curSession = sessionModule?.getCurrentSessionId() || ''; 269 let _anyTab = false;
270 for (const [id, doc] of docs) { 271 // Only show tabs for the current session 272 if (doc.sessionId && curSession && doc.sessionId !== curSession) continue; 273 _anyTab = true; 274 const isActive = id === activeDocId; 275 const title = doc.title || 'Untitled'; 276 const shortTitle = title.length > 24 ? title.slice(0, 22) + '...' : title; 277 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>`; 278 const ver = doc.version || doc.version_count || 1; 279 const verChip = `<span class="doc-tab-version" data-doc-id="${id}" title="Version history">v${ver}</span>`; 280 // Language icon before the title â same family as the meta-line / picker 281 // icons. Hidden via :empty CSS when the doc has no useful language. 282 const lic = (doc.language && doc.language !== 'text') 283 ? langIcon(doc.language, 12, { style: 'opacity:0.65;flex-shrink:0;color:currentColor;margin-right:4px;' }) 284 : ''; 285 const langChip = `<span class="doc-tab-lang">${lic}</span>`; 286 html += `<div class="doc-tab${isActive ? ' active' : ''}" draggable="true" data-doc-id="${id}" title="${title}"> 287 ${verChip}${langChip}<span class="doc-tab-title">${shortTitle}</span> 288 <button class="doc-tab-close" data-doc-id="${id}" title="Unlink from chat (kept in the Library)">×</button> 289 </div>`; 290 } 291 // Empty state (panel open, no doc yet): show a ghost "Untitled" tab so it's 292 // obvious you're in a fresh document rather than staring at a blank pane. 293 if (!_anyTab && isOpen && !activeDocId) { 294 html += `<div class="doc-tab active doc-tab-ghost" title="New document â start typing"><span class="doc-tab-title">Untitled</span></div>`; 295 } 296 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>`; 297 html += '</div>'; 298 html += '<button class="doc-tab-arrow doc-tab-arrow-right" id="doc-tab-right" title="Scroll right">›</button>'; 299 tabBar.innerHTML = html; 300 301 // Wire scroll arrows 302 const scrollArea = document.getElementById('doc-tab-scroll'); 303 const leftBtn = document.getElementById('doc-tab-left'); 304 const rightBtn = document.getElementById('doc-tab-right'); 305 if (scrollArea && leftBtn && rightBtn) { 306 leftBtn.addEventListener('click', () => scrollArea.scrollBy({ left: -120, behavior: 'smooth' })); 307 rightBtn.addEventListener('click', () => scrollArea.scrollBy({ left: 120, behavior: 'smooth' })); 308 updateArrowVisibility(scrollArea, leftBtn, rightBtn); 309 scrollArea.addEventListener('scroll', () => updateArrowVisibility(scrollArea, leftBtn, rightBtn)); 310 } 311 312 // Mobile: the tab bar doubles as a drag zone â swipe down to dismiss. 313 if (!tabBar._swipeWired) { tabBar._swipeWired = true; _wireSwipeDismiss(tabBar); } 314 315 // Bring the clicked tab fully into view â the scroll area has an 18px 316 // fade-mask at each edge plus the < / > arrow buttons; without this, the 317 // rightmost tab stays partially under the fade so the user can't see its 318 // close button or version chip. 319 const _scrollTabIntoView = (tab, behavior = 'smooth') => { 320 const sa = document.getElementById('doc-tab-scroll'); 321 if (!sa || !tab) return; 322 const EDGE_PAD = 30; 323 const tabLeft = tab.offsetLeft; 324 const tabRight = tabLeft + tab.offsetWidth; 325 const visLeft = sa.scrollLeft + EDGE_PAD; 326 const visRight = sa.scrollLeft + sa.clientWidth - EDGE_PAD; 327 if (tabRight > visRight) { 328 sa.scrollTo({ left: sa.scrollLeft + tabRight - visRight, behavior }); 329 } else if (tabLeft < visLeft) { 330 sa.scrollTo({ left: Math.max(0, sa.scrollLeft + tabLeft - visLeft), behavior }); 331 } 332 }; 333 // Wire tab clicks (delayed to allow dblclick on title) 334 let _tabClickTimer = null; 335 tabBar.querySelectorAll('.doc-tab').forEach(tab => { 336 tab.addEventListener('click', (e) => { 337 // Check if click was on or inside the close/play button 338 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; 339 if (_isEditingTabTitle) return; 340 // If clicking the title span, delay to allow dblclick 341 if (e.target.classList.contains('doc-tab-title')) { 342 clearTimeout(_tabClickTimer); 343 _tabClickTimer = setTimeout(() => { switchToDoc(tab.dataset.docId); _scrollTabIntoView(tab); }, 250); 344 } else { 345 switchToDoc(tab.dataset.docId); 346 _scrollTabIntoView(tab); 347 } 348 }); 349 tab.addEventListener('dblclick', (e) => { 350 clearTimeout(_tabClickTimer); 351 const titleSpan = tab.querySelector('.doc-tab-title'); 352 if (!titleSpan) return; 353 e.stopPropagation(); 354 const docId = tab.dataset.docId; 355 const doc = docs.get(docId); 356 if (!doc) return; 357 startTitleEdit(titleSpan, docId, doc); 358 }); 359 }); 360 361 // Wire close buttons â use delegation from tab bar for reliability 362 // Remove previous handler to prevent accumulation across renderTabs calls 363 if (tabBar._closeHandler) tabBar.removeEventListener('click', tabBar._closeHandler); 364 tabBar._closeHandler = (e) => { 365 const verBtn = e.target.closest('.doc-tab-version'); 366 if (verBtn) { 367 e.stopPropagation(); 368 const docId = verBtn.dataset.docId; 369 if (docId) { if (docId !== activeDocId) switchToDoc(docId); toggleVersionHistory(); } 370 return; 371 } 372 const playBtn = e.target.closest('.doc-tab-play'); 373 if (playBtn) { 374 e.stopPropagation(); 375 const docId = playBtn.dataset.docId; 376 if (docId) { 377 if (docId !== activeDocId) switchToDoc(docId); 378 toggleHtmlPreview(); 379 } 380 return; 381 } 382 const menuBtnEl = e.target.closest('.doc-tab-menu-btn'); 383 if (menuBtnEl) { 384 e.stopPropagation(); 385 const docId = menuBtnEl.dataset.docId; 386 if (docId) showDocTabMenu(menuBtnEl, docId); 387 return; 388 } 389 const closeBtn = e.target.closest('.doc-tab-close'); 390 if (!closeBtn) return; 391 e.stopPropagation(); 392 const docId = closeBtn.dataset.docId; 393 if (docId) closeTab(docId); 394 }; 395 tabBar.addEventListener('click', tabBar._closeHandler); 396 397 // Wire drag-to-reorder 398 initTabDragReorder(tabBar); 399 400 // Wire new doc button 401 const newBtn = document.getElementById('doc-tab-new-btn'); 402 if (newBtn) { 403 newBtn.addEventListener('click', async () => { 404 let sessionId = docs.get(activeDocId)?.sessionId 405 || _lastSessionId 406 || (sessionModule && sessionModule.getCurrentSessionId()); 407 if (!sessionId) { 408 try { 409 sessionId = await _autoCreateSession(); 410 } catch (e) { 411 console.error('Failed to auto-create session for document:', e); 412 return; 413 } 414 } 415 createDocument(sessionId); 416 }); 417 } 418 419 // Scroll active tab into view after DOM is laid out 420 requestAnimationFrame(() => { 421 const at = document.getElementById('doc-tab-scroll')?.querySelector('.doc-tab.active'); 422 _scrollTabIntoView(at, 'auto'); 423 }); 424 } 425
426 /** Start inline editing of a tab title */ 427 function startTitleEdit(titleSpan, docId, doc) { 428 if (_isEditingTabTitle) return; 429 _isEditingTabTitle = true; 430 431 const fullTitle = doc.title || ''; 432 const input = document.createElement('input'); 433 input.type = 'text'; 434 input.className = 'doc-tab-title-input'; 435 input.value = fullTitle; 436 437 titleSpan.replaceWith(input); 438 input.focus(); 439 input.select(); 440 441 function commitEdit() { 442 if (!_isEditingTabTitle) return; 443 const newTitle = input.value.trim(); 444 _isEditingTabTitle = false; 445 doc.title = newTitle; 446 if (docId === activeDocId) { 447 const titleInput = document.getElementById('doc-title-input'); 448 if (titleInput) titleInput.value = newTitle; 449 } 450 updateTitle(docId, newTitle); 451 renderTabs(); 452 } 453 454 function cancelEdit() { 455 _isEditingTabTitle = false; 456 renderTabs(); 457 } 458 459 input.addEventListener('blur', commitEdit); 460 input.addEventListener('keydown', (e) => { 461 if (e.key === 'Enter') { 462 e.preventDefault(); 463 input.removeEventListener('blur', commitEdit); 464 commitEdit(); 465 } else if (e.key === 'Escape') { 466 e.preventDefault(); 467 input.removeEventListener('blur', commitEdit); 468 cancelEdit(); 469 } 470 }); 471 } 472 473 /** Drag-to-reorder tabs */ 474 function initTabDragReorder(tabBar) { 475 let dragId = null; 476 477 tabBar.querySelectorAll('.doc-tab').forEach(tab => { 478 tab.addEventListener('dragstart', (e) => { 479 dragId = tab.dataset.docId; 480 tab.classList.add('dragging'); 481 e.dataTransfer.effectAllowed = 'move'; 482 }); 483 484 tab.addEventListener('dragend', () => { 485 tab.classList.remove('dragging'); 486 dragId = null; 487 tabBar.querySelectorAll('.doc-tab').forEach(t => t.classList.remove('drag-over')); 488 }); 489 490 tab.addEventListener('dragover', (e) => { 491 e.preventDefault(); 492 e.dataTransfer.dropEffect = 'move'; 493 if (tab.dataset.docId !== dragId) { 494 tab.classList.add('drag-over'); 495 } 496 }); 497 498 tab.addEventListener('dragleave', () => { 499 tab.classList.remove('drag-over'); 500 }); 501 502 tab.addEventListener('drop', (e) => { 503 e.preventDefault(); 504 tab.classList.remove('drag-over'); 505 const targetId = tab.dataset.docId; 506 if (!dragId || dragId === targetId) return; 507 508 // Reorder the docs Map: move dragId before targetId 509 const entries = [...docs.entries()]; 510 const fromIdx = entries.findIndex(([k]) => k === dragId); 511 const toIdx = entries.findIndex(([k]) => k === targetId); 512 if (fromIdx === -1 || toIdx === -1) return; 513 514 const [moved] = entries.splice(fromIdx, 1); 515 entries.splice(toIdx, 0, moved); 516 517 docs.clear(); 518 for (const [k, v] of entries) docs.set(k, v); 519 520 renderTabs(); 521 }); 522 }); 523 } 524 525 /** Show empty state when no documents exist yet */ 526 function showEmptyState() { 527 activeDocId = null; 528 const textarea = document.getElementById('doc-editor-textarea'); 529 const langSelect = document.getElementById('doc-language-select'); 530 const badge = document.getElementById('doc-version-badge'); 531 532 if (textarea) textarea.value = ''; 533 if (textarea) textarea.placeholder = 'Start typing or paste text to create a document...'; 534 if (textarea) textarea.disabled = false; 535 if (langSelect) langSelect.value = ''; 536 if (badge) badge.textContent = ''; 537 _hideLoadingOverlay(); 538 syncHighlighting(); 539 renderTabs(); 540 } 541 542 let _loadingSpinner = null; 543 function _showLoadingOverlay() { 544 const wrap = document.getElementById('doc-editor-wrap'); 545 if (!wrap) return; 546 let overlay = wrap.querySelector('.doc-loading-overlay'); 547 if (!overlay) { 548 overlay = document.createElement('div'); 549 overlay.className = 'doc-loading-overlay'; 550 wrap.appendChild(overlay); 551 } 552 overlay.innerHTML = ''; 553 overlay.style.display = ''; 554 _loadingSpinner = spinnerModule.create('', 'clean', 'whirlpool'); 555 const el = _loadingSpinner.createElement(); 556 overlay.appendChild(el); 557 _loadingSpinner.start(); 558 } 559 560 function _hideLoadingOverlay() { 561 if (_loadingSpinner) { _loadingSpinner.destroy(); _loadingSpinner = null; }
562 const overlay = document.querySelector('.doc-loading-overlay'); 563 if (overlay) overlay.style.display = 'none'; 564 } 565 566 /** Show/hide the unified action button in the header based on current language */ 567 function _isFormBackedDoc(content) { 568 const c = content || ''; 569 return /<!--\s*pdf_form_source\s+upload_id="[^"]+"/.test(c) 570 || /<!--\s*pdf_source\s+upload_id="[^"]+"/.test(c); 571 } 572 573 // Force the on-screen keyboard down on touch. Firefox mobile ignores a plain 574 // blur, so use the readonly trick (a readonly field shows no keyboard), then 575 // drop readonly so the user can type again. 576 function _dismissDocKb() { 577 if (!(('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0)) return; 578 const ta = document.getElementById('doc-editor-textarea'); 579 const ae = document.activeElement; 580 const el = (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) ? ae : ta; 581 if (!el) return; 582 try { 583 el.setAttribute('readonly', 'readonly'); 584 el.blur(); 585 setTimeout(() => { try { el.removeAttribute('readonly'); } catch (_) {} }, 120); 586 } catch (_) { try { el.blur(); } catch (_) {} } 587 } 588 589 async function _downloadFilledPdf() { 590 if (!activeDocId) return; 591 _dismissDocKb(); // export shouldn't leave the keyboard up 592 await _saveActiveDocBeforeExport(); 593 try { 594 const r = await fetch(`${API_BASE}/api/document/${activeDocId}/export-pdf`); 595 if (!r.ok) { 596 const t = await r.text(); 597 throw new Error(t || r.statusText); 598 } 599 const blob = await r.blob(); 600 const url = URL.createObjectURL(blob); 601 const a = document.createElement('a'); 602 a.href = url; 603 const cd = r.headers.get('Content-Disposition') || ''; 604 const m = cd.match(/filename\*?=(?:UTF-8'')?"?([^"';]+)/i); 605 const _slug = (s) => (s || 'form').replace(/\.pdf$/i, '').replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '') || 'form'; 606 a.download = (m && decodeURIComponent(m[1])) || (_slug(docs.get(activeDocId)?.title) + '_annotated.pdf'); 607 document.body.appendChild(a); 608 a.click(); 609 a.remove(); 610 setTimeout(() => URL.revokeObjectURL(url), 1000); 611 } catch (e) { 612 if (uiModule) uiModule.showError('Export failed: ' + e.message); 613 else alert('Export failed: ' + e.message); 614 } 615 } 616 617 async function _saveActiveDocBeforeExport() { 618 // Flush in-flight edits from BOTH editing surfaces so the server-side 619 // export reads the values the user actually sees: 620 // - Markdown view: textarea.value may differ from doc.content if the 621 // user typed but the existing 2s autosave hasn't fired. 622 // - PDF view: there may be a pending debounced _pdfPaneSaveTimer that 623 // hasn't flushed the user's input changes yet. 624 if (_pdfPaneSaveTimer) { 625 clearTimeout(_pdfPaneSaveTimer); 626 await _savePdfPaneToMarkdown(); 627 } 628 const ta = document.getElementById('doc-editor-textarea'); 629 const doc = docs.get(activeDocId); 630 if (!ta || !doc || !activeDocId) return; 631 const live = ta.value; 632 if (live === doc.content) return; 633 try { 634 await fetch(`${API_BASE}/api/document/${activeDocId}`, { 635 method: 'PUT', 636 headers: { 'Content-Type': 'application/json' }, 637 body: JSON.stringify({ content: live }), 638 }); 639 doc.content = live; 640 } catch (e) { 641 console.warn('Pre-export save failed:', e); 642 } 643 } 644 645 async function _openExportPdfModal() { 646 if (!activeDocId) return; 647 await _saveActiveDocBeforeExport(); 648 649 const overlay = document.createElement('div'); 650 overlay.className = 'modal pdf-export-overlay'; 651 overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);'; 652 overlay.innerHTML = ` 653 <div class="modal-content" style="width:min(780px,94vw);max-height:86vh;"> 654 <div class="modal-header"> 655 <h4>Export filled PDF</h4> 656 <button id="pdf-export-close" class="modal-close" title="Close">Ã</button> 657 </div> 658 <div id="pdf-export-summary" style="font-size:0.78rem;opacity:0.7;margin:0 0 6px;">Loading field valuesâ¦</div> 659 <div id="pdf-export-body" class="modal-body" style="font-size:0.85rem;"> 660 <div style="opacity:0.6;">Fetching mappingâ¦</div> 661 </div> 662 <div class="modal-footer" style="display:flex;justify-content:flex-end;
662gap:8px;padding-top:8px;border-top:1px solid var(--border);margin-top:6px;align-items:center;"> 663 <span id="pdf-export-status" style="font-size:0.75rem;opacity:0.7;margin-right:auto;"></span> 664 <button id="pdf-export-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button> 665 <button id="pdf-export-download" class="confirm-btn confirm-btn-primary" disabled>Download PDF</button> 666 </div> 667 </div> 668 `; 669 document.body.appendChild(overlay); 670 671 const close = () => overlay.remove(); 672 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 673 overlay.querySelector('#pdf-export-close').addEventListener('click', close); 674 overlay.querySelector('#pdf-export-cancel').addEventListener('click', close); 675 676 let fields = []; 677 try { 678 const res = await fetch(`${API_BASE}/api/document/${activeDocId}/export-pdf/preview`, { method: 'POST' }); 679 if (!res.ok) { 680 const err = await res.text(); 681 throw new Error(err || res.statusText); 682 } 683 const data = await res.json(); 684 fields = data.fields || []; 685 686 const filledNow = data.filled || 0; 687 const total = data.total || fields.length; 688 overlay.querySelector('#pdf-export-summary').textContent = 689 `${filledNow} of ${total} fields filled. Review and adjust below before downloading.`; 690 691 const body = overlay.querySelector('#pdf-export-body'); 692 body.innerHTML = ''; 693 694 // Group by page 695 const byPage = new Map(); 696 for (const f of fields) { 697 const p = f.page || 1; 698 if (!byPage.has(p)) byPage.set(p, []); 699 byPage.get(p).push(f); 700 } 701 const pages = Array.from(byPage.keys()).sort((a, b) => a - b); 702 703 // Jump bar: page links + scroll-to-top/bottom shortcuts 704 const jumpBar = document.createElement('div'); 705 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;'; 706 jumpBar.innerHTML = '<span style="opacity:0.6;margin-right:4px;">Jump to:</span>'; 707 const pageAnchors = {}; 708 const _smallBtnClass = 'confirm-btn confirm-btn-secondary'; 709 const _smallBtnStyle = 'padding:2px 8px;font-size:0.72rem;'; 710 for (const p of pages) { 711 const a = document.createElement('button'); 712 a.textContent = String(p); 713 a.title = `Jump to page ${p}`; 714 a.className = _smallBtnClass; 715 a.style.cssText = _smallBtnStyle; 716 a.addEventListener('click', () => pageAnchors[p]?.scrollIntoView({ behavior: 'smooth', block: 'start' })); 717 jumpBar.appendChild(a); 718 } 719 const sep = document.createElement('span'); 720 sep.style.cssText = 'opacity:0.4;margin:0 4px;'; 721 sep.textContent = '|'; 722 jumpBar.appendChild(sep); 723 const topBtn = document.createElement('button'); 724 topBtn.textContent = 'â Top'; 725 topBtn.className = _smallBtnClass; 726 topBtn.style.cssText = _smallBtnStyle; 727 topBtn.addEventListener('click', () => body.scrollTo({ top: 0, behavior: 'smooth' })); 728 jumpBar.appendChild(topBtn); 729 const botBtn = document.createElement('button'); 730 botBtn.textContent = 'â Bottom'; 731 botBtn.title = 'Jump to the last page (signature fields are usually here)'; 732 botBtn.className = _smallBtnClass; 733 botBtn.style.cssText = _smallBtnStyle; 734 botBtn.addEventListener('click', () => body.scrollTo({ top: body.scrollHeight, behavior: 'smooth' })); 735 jumpBar.appendChild(botBtn); 736 body.appendChild(jumpBar); 737 738 for (const p of pages) { 739 const sec = document.createElement('div'); 740 sec.className = 'pdf-export-section'; 741 sec.id = `pdf-export-page-${p}`; 742 pageAnchors[p] = sec; 743 sec.innerHTML = `<div class="pdf-export-section-title">Page ${p}</div>`; 744 for (const f of byPage.get(p)) { 745 const row = document.createElement('div'); 746 row.className = 'pdf-export-row'; 747 const label = document.createElement('label'); 748 label.textContent = f.label || f.name; 749 label.title = `${f.name} (${f.type})`; 750 row.appendChild(label); 751 752 const isSignature = f.type === 'signature' || /sign(?:ed|ature)/i.test((f.name || '') + ' ' + (f.label || '')); 753 const isDate = f.type === 'text' && /\b(date|dated)\b/i.test(`${f.name || ''} ${f.label || ''}`); 754 let input; 755 if (isSignature) { 756 const wrap = document.createElement('div'); 757 wrap.style.cssText = 'display:flex;align-items:center;gap:8px;'; 758 const btn = document.createElement('button'); 759 btn.className = 'confirm-btn confirm-btn-secondary'; 760 btn.style.cssText = 'padding:3px 10px;font-size:0.78rem;'; 761 const thumb = document.createElement('img'); 762 thumb.style.cssText = 'max-height:32px;max-width:140px;object-fit:contain;border:1px solid var(--border);border-radius:3px;background:#fff;display:none;'; 763 const clearBtn = document.createElement('button'); 764 clearBtn.textContent = 'Ã'; 765 clearBtn.title = 'Remove signature from this field'; 766 clearBtn.className = 'confirm-btn confirm-btn-secondary'; 767 clearBtn.style.cssText = 'padding:0 8px;font-size:0.85rem;line-height:1;display:none;'; 768 const apply = (sig) => { 769 wrap.dataset.signatureId = sig.id; 770 thumb.src = sig.dataUrl; 771 thumb.style.display = ''; 772 clearBtn.style.display = ''; 773 btn.textContent = 'Change'; 774 }; 775 const clear = () => { 776 delete wrap.dataset.signatureId; 777 thumb.removeAttribute('src'); 778 thumb.style.display = 'none'; 779 clearBtn.style.display = 'none'; 780 btn.textContent = 'Sign here'; 781 }; 782 btn.textContent = 'Sign here'; 783 btn.addEventListener('click', async () => { 784 const sig = await signatureModule.pick(); 785 if (sig) apply(sig); 786 }); 787 clearBtn.addEventListener('click', clear); 788 wrap.appendChild(btn); 789 wrap.appendChild(thumb); 790 wrap.appendChild(clearBtn);
791 wrap.dataset.fieldName = f.name; 792 wrap.dataset.fieldType = 'signature'; 793 const last = signatureModule.getLastUsed && signatureModule.getLastUsed(); 794 if (last) apply(last); 795 input = wrap; 796 } else if (isDate) { 797 const wrap = document.createElement('div'); 798 wrap.style.cssText = 'display:flex;gap:6px;align-items:center;'; 799 const ti = document.createElement('input'); 800 ti.type = 'text'; 801 ti.value = f.value == null ? '' : String(f.value); 802 ti.className = 'pdf-export-input'; 803 ti.style.cssText = 'flex:1;'; 804 ti.dataset.fieldName = f.name; 805 ti.dataset.fieldType = f.type; 806 const today = document.createElement('button'); 807 today.textContent = 'Today'; 808 today.title = "Set to today's date"; 809 today.className = 'confirm-btn confirm-btn-secondary'; 810 today.style.cssText = 'padding:3px 8px;font-size:0.72rem;'; 811 today.addEventListener('click', () => { 812 const d = new Date(); 813 const dd = String(d.getDate()).padStart(2, '0'); 814 const mm = String(d.getMonth() + 1).padStart(2, '0'); 815 const yyyy = d.getFullYear(); 816 ti.value = `${dd}/${mm}/${yyyy}`; 817 }); 818 wrap.appendChild(ti); 819 wrap.appendChild(today); 820 input = wrap; 821 } else if (f.type === 'checkbox') { 822 input = document.createElement('input'); 823 input.type = 'checkbox'; 824 input.checked = !!f.value; 825 } else if (f.type === 'choice' && (f.options || []).length) { 826 input = document.createElement('select'); 827 input.className = 'pdf-export-input'; 828 const blank = document.createElement('option'); 829 blank.value = ''; 830 blank.textContent = 'â (none) â'; 831 input.appendChild(blank); 832 for (const o of f.options) { 833 const opt = document.createElement('option'); 834 opt.value = o; opt.textContent = o; 835 if (o === f.value) opt.selected = true; 836 input.appendChild(opt); 837 } 838 } else { 839 input = document.createElement('input'); 840 input.type = 'text'; 841 input.value = f.value == null ? '' : String(f.value); 842 input.className = 'pdf-export-input'; 843 input.style.cssText = 'width:100%;'; 844 } 845 if (!isSignature && !isDate) { 846 input.dataset.fieldName = f.name; 847 input.dataset.fieldType = f.type; 848 } 849 row.appendChild(input); 850 sec.appendChild(row); 851 } 852 body.appendChild(sec); 853 } 854 855 const downloadBtn = overlay.querySelector('#pdf-export-download'); 856 downloadBtn.disabled = false; 857 downloadBtn.addEventListener('click', async () => { 858 const values = {}; 859 const signatures = {}; 860 for (const el of overlay.querySelectorAll('[data-field-name]')) { 861 const name = el.dataset.fieldName; 862 const ftype = el.dataset.fieldType; 863 if (ftype === 'signature') { 864 if (el.dataset.signatureId) signatures[name] = el.dataset.signatureId; 865 } else if (ftype === 'checkbox') { 866 values[name] = el.checked; 867 } else { 868 values[name] = el.value; 869 } 870 } 871 downloadBtn.disabled = true; 872 overlay.querySelector('#pdf-export-status').textContent = 'Building PDFâ¦'; 873 try { 874 const r = await fetch(`${API_BASE}/api/document/${activeDocId}/export-pdf`, { 875 method: 'POST', 876 headers: { 'Content-Type': 'application/json' }, 877 body: JSON.stringify({ values, signatures }), 878 }); 879 if (!r.ok) { 880 const t = await r.text(); 881 throw new Error(t || r.statusText); 882 } 883 const blob = await r.blob(); 884 const url = URL.createObjectURL(blob); 885 const a = document.createElement('a'); 886 a.href = url; 887 const cd = r.headers.get('Content-Disposition') || '';
888 const m = cd.match(/filename\*?=(?:UTF-8'')?"?([^"';]+)/i); 889 const _slug = (s) => (s || 'form').replace(/\.pdf$/i, '').replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '') || 'form'; 890 a.download = (m && decodeURIComponent(m[1])) || (_slug(docs.get(activeDocId)?.title) + '_annotated.pdf'); 891 document.body.appendChild(a); 892 a.click(); 893 a.remove(); 894 setTimeout(() => URL.revokeObjectURL(url), 1000); 895 close(); 896 } catch (e) { 897 overlay.querySelector('#pdf-export-status').textContent = 'Error: ' + e.message; 898 downloadBtn.disabled = false; 899 } 900 }); 901 } catch (e) { 902 overlay.querySelector('#pdf-export-body').innerHTML = 903 `<div style="color:#c00;">Failed to load preview: ${(e && e.message) || e}</div>`; 904 } 905 } 906 907 // Tracks which form-backed docs the user has toggled into PDF view 908 // (per-doc, in-memory). Survives switches between docs in the same session. 909 const _pdfViewState = new Map(); 910 const _pdfPaneFieldsByDoc = new Map(); // docId -> [{name, type, inputEl, ...}] 911 const _pdfPaneAnnotationsByDoc = new Map(); // docId -> [{id, page, x, y, w, h, el, wrap}] 912 const _pdfUndoStackByDoc = new Map(); // docId -> markdown snapshots 913 let _pdfPaneSaveTimer = null; 914 915 // Match a freeform-annotation bullet line in the markdown source. 916 // Coords are percentages of page width/height (0â100) so they scale with 917 // however wide the PDF pane is rendered. `kind` and `lh` (line-height) 918 // are optional for backward compat with earlier annotation formats. 919 function _annotationRegexGlobal() { 920 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; 921 } 922 923 // Bullet lines are single-line, so newlines in the value are escaped to 924 // \n (backslash-n) for storage and unescaped on parse. Backslashes are 925 // escaped first so the reverse mapping is unambiguous. 926 function _escapeAnnotationValue(s) { 927 return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); 928 } 929 function _unescapeAnnotationValue(s) { 930 return String(s || '').replace(/\\(.)/g, (m, c) => c === 'n' ? '\n' : c === '\\' ? '\\' : m); 931 } 932 933 function _parseAnnotations(md) { 934 const out = []; 935 const re = _annotationRegexGlobal(); 936 let m; 937 while ((m = re.exec(md || '')) !== null) { 938 const rawVal = m[1] === '_(empty)_' ? '' : _unescapeAnnotationValue(m[1]); 939 out.push({ 940 value: rawVal, 941 id: m[2], 942 page: parseInt(m[3], 10), 943 x: parseFloat(m[4]), 944 y: parseFloat(m[5]), 945 w: parseFloat(m[6]), 946 h: parseFloat(m[7]), 947 kind: m[8] || 'text', 948 lineHeight: m[9] ? parseFloat(m[9]) : 1.3, 949 }); 950 } 951 return out; 952 } 953 954 function _annotationLine(a) { 955 const kind = a.kind || 'text'; 956 const lh = (a.lineHeight && Number.isFinite(a.lineHeight)) ? a.lineHeight : 1.3; 957 const escaped = a.value === '' || a.value == null ? '_(empty)_' : _escapeAnnotationValue(a.value); 958 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)} -->`; 959 } 960 961 // Strip every annotation bullet + the "## Annotations" section, then 962 // re-emit them at the end. Cleanest way to keep the section in sync with 963 // the live set of refs without diffing line-by-line. 964 function _writeAnnotations(md, annotations) { 965 let out = (md || '').replace(_annotationRegexGlobal(), ''); 966 out = out.replace(/\n##\s+Annotations\s*\r?\n+/g, '\n'); 967 out = out.replace(/\n{3,}/g, '\n\n'); 968 if (!annotations.length) return out; 969 if (!out.endsWith('\n')) out += '\n'; 970 out += '\n## Annotations\n\n'; 971 for (const a of annotations) out += _annotationLine(a) + '\n'; 972 return out; 973 } 974 975 function _newAnnotationId() { 976 return 'ann-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7); 977 } 978 979 function _pdfMarkdownFromLive(docId = activeDocId) { 980 const doc = docs.get(docId); 981 if (!doc) return null; 982 const annotations = _pdfPaneAnnotationsByDoc.get(docId) || []; 983 return _writeAnnotations(doc.content || '', annotations.map(a => { 984 let value = ''; 985 if (a.kind === 'check') { 986 value = 'â'; 987 } else if (a.kind === 'signature') { 988 const sid = a.el && a.el.dataset && a.el.dataset.signatureId; 989 value = sid ? `signature:${sid}` : ''; 990 } else { 991 value = (a.el && typeof a.el.value === 'string') ? a.el.value : ''; 992 } 993 return { 994 id: a.id, page: a.page, x: a.x, y: a.y, w: a.w, h: a.h, 995 kind: a.kind || 'text', 996 lineHeight: a.lineHeight || 1.3, 997 value, 998 }; 999 })); 1000 } 1001
1002 function _pushPdfUndoSnapshot(docId = activeDocId) { 1003 const md = _pdfMarkdownFromLive(docId); 1004 if (md == null) return; 1005 const stack = _pdfUndoStackByDoc.get(docId) || []; 1006 if (stack[stack.length - 1] === md) return; 1007 stack.push(md); 1008 if (stack.length > 50) stack.shift(); 1009 _pdfUndoStackByDoc.set(docId, stack); 1010 } 1011 1012 async function _undoPdfPaneAction() { 1013 const docId = activeDocId; 1014 const stack = _pdfUndoStackByDoc.get(docId) || []; 1015 const prev = stack.pop(); 1016 if (!prev) return false; 1017 _pdfUndoStackByDoc.set(docId, stack); 1018 if (_pdfPaneSaveTimer) { 1019 clearTimeout(_pdfPaneSaveTimer); 1020 _pdfPaneSaveTimer = null; 1021 } 1022 const doc = docs.get(docId); 1023 if (!doc) return false; 1024 doc.content = prev; 1025 const ta = document.getElementById('doc-editor-textarea'); 1026 if (ta) ta.value = prev; 1027 _setPdfSaveStatus('saving'); 1028 try { 1029 const res = await fetch(`${API_BASE}/api/document/${docId}`, { 1030 method: 'PUT', 1031 headers: { 'Content-Type': 'application/json' }, 1032 body: JSON.stringify({ content: prev }), 1033 }); 1034 if (!res.ok) throw new Error(res.statusText || String(res.status)); 1035 _setPdfSaveStatus('saved'); 1036 _renderPdfPane(); 1037 return true; 1038 } catch (e) { 1039 _setPdfSaveStatus('error', e.message || 'Undo failed'); 1040 return true; 1041 } 1042 } 1043 1044 // Active drop mode for the PDF toolbar â toolbar buttons set this; the 1045 // next click on a page consumes it. null means clicks do nothing. 1046 let _pdfDropMode = null; 1047 // Per-doc last-used line spacing for text annotations. Once the user picks 1048 // 1.6 for one box, every text box dropped after that defaults to 1.6. 1049 const _pdfLastLineHeight = new Map(); // docId -> number 1050 function _setPdfDropMode(mode) { 1051 _pdfDropMode = mode; 1052 const pane = document.getElementById('doc-pdf-view'); 1053 if (pane) pane.style.cursor = mode ? 'crosshair' : ''; 1054 // Highlight the active toolbar button so users see which mode is on. 1055 for (const id of ['doc-pdf-add-text-btn', 'doc-pdf-add-check-btn', 'doc-pdf-add-sign-btn']) { 1056 const b = document.getElementById(id); 1057 if (!b) continue; 1058 const want = (mode === 'text' && id === 'doc-pdf-add-text-btn') 1059 || (mode === 'check' && id === 'doc-pdf-add-check-btn') 1060 || (mode === 'signature' && id === 'doc-pdf-add-sign-btn'); 1061 b.style.outline = want ? '2px solid var(--accent-primary, var(--red))' : ''; 1062 } 1063 } 1064 // Cache of signature data URLs by id, populated lazily as the PDF view 1065 // renders inline signatures and as the user picks new ones. 1066 const _sigCache = new Map(); 1067 1068 // Mirror of Python _encode_name in src/pdf_form_doc.py â keep in sync. 1069 // Percent-encode everything that's not A-Za-z0-9 _ . - 1070 function _encodeFieldName(name) { 1071 let out = ''; 1072 for (const ch of name || '') { 1073 if (/[A-Za-z0-9_.\-]/.test(ch)) { 1074 out += ch; 1075 } else { 1076 const enc = new TextEncoder().encode(ch); 1077 for (const b of enc) out += '%' + b.toString(16).toUpperCase().padStart(2, '0'); 1078 } 1079 } 1080 return out; 1081 } 1082 1083 // Proximity-based handle visibility â show Ã/drag/resize handles whenever 1084 // the cursor gets within ~30px of an annotation, not only when it's inside. 1085 // Attached once to the pane; reads the current doc's refs at fire time. 1086 let _pdfPaneProximityWired = false; 1087 function _wirePdfPaneProximity(pane) { 1088 if (_pdfPaneProximityWired || !pane) return; 1089 _pdfPaneProximityWired = true; 1090 let raf = 0; 1091 const buffer = 30; 1092 pane.addEventListener('mousemove', (ev) => { 1093 if (raf) return; 1094 raf = requestAnimationFrame(() => { 1095 raf = 0; 1096 const refs = _pdfPaneAnnotationsByDoc.get(activeDocId) || []; 1097 for (const ref of refs) { 1098 if (!ref || !ref.wrap || !ref._setHandlesVisible) continue; 1099 const r = ref.wrap.getBoundingClientRect(); 1100 const dx = Math.max(r.left - ev.clientX, 0, ev.clientX - r.right); 1101 const dy = Math.max(r.top - ev.clientY, 0, ev.clientY - r.bottom); 1102 ref._setHandlesVisible(Math.hypot(dx, dy) <= buffer); 1103 } 1104 }); 1105 }); 1106 pane.addEventListener('mouseleave', () => { 1107 const refs = _pdfPaneAnnotationsByDoc.get(activeDocId) || []; 1108 for (const ref of refs) ref._setHandlesVisible && ref._setHandlesVisible(false); 1109 }); 1110 } 1111 1112 async function _pdfResponseErrorMessage(res) { 1113 const text = await res.text().catch(() => ''); 1114 try { 1115 const data = JSON.parse(text); 1116 if (typeof data?.detail === 'string') return data.detail; 1117 if (data?.detail) return JSON.stringify(data.detail); 1118 } catch (_) {} 1119 return text || res.statusText || `HTTP ${res.status}`; 1120 } 1121 1122 async function _renderPdfPane() { 1123 const pane = document.getElementById('doc-pdf-view'); 1124 if (!pane || !activeDocId) return; 1125 _wirePdfPaneProximity(pane); 1126 const docId = activeDocId; 1127 // Keep the save pill across re-renders by detaching/re-attaching it 1128 const savedPill = document.getElementById('doc-pdf-save-pill'); 1129 pane.innerHTML = '<div style="color:#bbb;font-size:13px;text-align:center;padding:40px;">Loading PDFâ¦</div>'; 1130 if (savedPill) pane.appendChild(savedPill); 1131 let data; 1132 try { 1133 const res = await fetch(`${API_BASE}/api/document/${docId}/render-pages`); 1134 if (!res.ok) throw new Error(await _pdfResponseErrorMessage(res)); 1135 data = await res.json(); 1136 } catch (e) { 1137 pane.innerHTML = `<div style="color:#fbb;padding:40px;text-align:center;">Failed to load PDF view: ${_escHtml(e.message || String(e))}</div>`; 1138 if (savedPill) pane.appendChild(savedPill); 1139 return; 1140 } 1141 if (docId !== activeDocId) return; 1142 1143 pane.innerHTML = ''; 1144 if (savedPill) pane.appendChild(savedPill); 1145 const fieldRefs = []; 1146 // Reset annotation refs for this doc before the page loop â we rebuild them 1147 // page by page from the live markdown. 1148 const annotationRefs = []; 1149 _pdfPaneAnnotationsByDoc.set(docId, annotationRefs); 1150 const liveMd = (docs.get(docId) && docs.get(docId).content) || '';
1151 const allAnnotations = _parseAnnotations(liveMd); 1152 // Recover the last-used line spacing from existing text annotations so the 1153 // pref survives page reload, not just the in-memory life of this session. 1154 if (!_pdfLastLineHeight.has(docId)) { 1155 for (let i = allAnnotations.length - 1; i >= 0; i--) { 1156 const a = allAnnotations[i]; 1157 if (a.kind === 'text' && a.lineHeight) { 1158 _pdfLastLineHeight.set(docId, a.lineHeight); 1159 break; 1160 } 1161 } 1162 } 1163 for (const page of data.pages) { 1164 // Lock the wrap to the page's exact aspect ratio so percentage-positioned 1165 // inputs stay aligned no matter how wide the panel is rendered. 1166 const pageWrap = document.createElement('div'); 1167 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;`; 1168 const img = document.createElement('img'); 1169 img.src = `${API_BASE}/api/document/${docId}/page/${page.page}.png`; 1170 img.style.cssText = 'display:block;width:100%;height:100%;user-select:none;-webkit-user-drag:none;pointer-events:none;'; 1171 img.draggable = false; 1172 pageWrap.appendChild(img); 1173 1174 // Scale-aware overlay so inputs track if the page wrap shrinks below 1175 // its natural width (we set width:page.width but max-width:100% caps it). 1176 // Each field is positioned via percentages of the page rect. 1177 for (const f of page.fields) { 1178 const [x0, y0, x1, y1] = f.rect_px; 1179 const wPct = ((x1 - x0) / page.width) * 100; 1180 const hPct = ((y1 - y0) / page.height) * 100; 1181 const lPct = (x0 / page.width) * 100; 1182 const tPct = (y0 / page.height) * 100; 1183 const isSig = f.type === 'signature' || /sign(?:ed|ature)/i.test((f.name || '') + ' ' + (f.label || '')); 1184 let el; 1185 const baseStyle = `position:absolute;left:${lPct}%;top:${tPct}%;width:${wPct}%;height:${hPct}%;box-sizing:border-box;font-family:inherit;`; 1186 if (isSig) { 1187 // Inline signature: click to pick / change. The selected signature 1188 // ID is mirrored into the markdown bullet as `signature:<id>` via 1189 // the existing debounced save flow, which the export route reads. 1190 el = document.createElement('div'); 1191 el.style.cssText = baseStyle + 'cursor:pointer;display:flex;align-items:center;justify-content:center;overflow:hidden;'; 1192 el.dataset.fieldName = f.name; 1193 el.dataset.fieldType = 'signature'; 1194 1195 // Parse pre-existing selection from value: `signature:<id>` shape 1196 const initialSigId = (typeof f.value === 'string' && f.value.startsWith('signature:')) 1197 ? f.value.slice('signature:'.length).trim() : ''; 1198 const renderSigUI = async (sigId) => { 1199 el.innerHTML = ''; 1200 if (sigId) { 1201 el.dataset.signatureId = sigId; 1202 const img = document.createElement('img'); 1203 img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 1204 // Look up the signature data URL via the saved-list cache or fetch 1205 try { 1206 if (!_sigCache.has(sigId)) { 1207 const r = await fetch(`${API_BASE}/api/signatures`); 1208 const data = await r.json(); 1209 for (const s of data.signatures || []) _sigCache.set(s.id, s.data_url); 1210 } 1211 const dataUrl = _sigCache.get(sigId); 1212 if (dataUrl) img.src = dataUrl; 1213 else throw new Error('not found'); 1214 el.appendChild(img); 1215 el.style.border = '1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent)'; 1216 el.style.background = 'transparent'; 1217 } catch { 1218 el.removeAttribute('data-signature-id'); 1219 renderSigUI(''); 1220 } 1221 } else { 1222 delete el.dataset.signatureId; 1223 el.style.border = '1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent)'; 1224 el.style.background = 'color-mix(in srgb, var(--accent, var(--red)) 10%, transparent)'; 1225 const span = document.createElement('span'); 1226 span.style.cssText = 'color:var(--accent, var(--red));font-size:11px;'; 1227 span.textContent = 'Sign here'; 1228 el.appendChild(span); 1229 } 1230 }; 1231 el.addEventListener('click', async (ev) => { 1232 ev.stopPropagation(); 1233 const sig = await signatureModule.pick(); 1234 if (sig) { 1235 _sigCache.set(sig.id, sig.dataUrl); 1236 await renderSigUI(sig.id); 1237 _schedulePdfPaneSave(); 1238 } 1239 }); 1240 renderSigUI(initialSigId); 1241 } else if (f.type === 'checkbox') { 1242 el = document.createElement('input'); 1243 el.type = 'checkbox'; 1244 el.checked = !!f.value; 1245 el.style.cssText = baseStyle + 'cursor:pointer;'; 1246 } else if (f.type === 'choice' && (f.options || []).length) { 1247 el = document.createElement('select'); 1248 const blank = document.createElement('option'); 1249 blank.value = ''; blank.textContent = 'â'; 1250 el.appendChild(blank); 1251 for (const opt of f.options) { 1252 const o = document.createElement('option'); 1253 o.value = opt; o.textContent = opt; 1254 if (opt === f.value) o.selected = true; 1255 el.appendChild(o); 1256 } 1257 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;'; 1258 } else { 1259 el = document.createElement('input'); 1260 el.type = 'text'; 1261 el.value = f.value == null ? '' : String(f.value); 1262 // Pick a font-size that roughly fits the field height. Smaller 1263 // multiplier than line-height to leave breathing room and match 1264 // what AcroForm renderers typically use. 1265 const fontPx = Math.max(8, Math.min(14, Math.round((y1 - y0) * 0.4))); 1266 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;`; 1267 } 1268 if (!isSig) {
1269 el.dataset.fieldName = f.name; 1270 el.dataset.fieldType = f.type; 1271 el.addEventListener('input', _schedulePdfPaneSave); 1272 el.addEventListener('change', _schedulePdfPaneSave); 1273 } 1274 pageWrap.appendChild(el); 1275 // Signature fields are also persisted via the markdown bullet â the 1276 // click handler invokes _schedulePdfPaneSave directly after picking. 1277 fieldRefs.push({ name: f.name, type: isSig ? 'signature' : f.type, el }); 1278 1279 // Date-field shortcut: any text field whose name or label hints at 1280 // a date gets a small "Today" button anchored to its right edge. 1281 const isDate = f.type === 'text' && /\b(date|dated)\b/i.test(`${f.name} ${f.label}`); 1282 if (isDate) { 1283 const today = document.createElement('button'); 1284 today.type = 'button'; 1285 today.textContent = 'Today'; 1286 today.title = "Set to today's date"; 1287 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;`; 1288 today.addEventListener('click', () => { 1289 const d = new Date(); 1290 const dd = String(d.getDate()).padStart(2, '0'); 1291 const mm = String(d.getMonth() + 1).padStart(2, '0'); 1292 const yyyy = d.getFullYear(); 1293 el.value = `${dd}/${mm}/${yyyy}`; 1294 _schedulePdfPaneSave(); 1295 }); 1296 pageWrap.appendChild(today); 1297 } 1298 } 1299 // Freeform annotations for this page 1300 for (const ann of allAnnotations) { 1301 if (ann.page !== page.page) continue; 1302 const built = _buildAnnotation(pageWrap, ann); 1303 annotationRefs.push(built.ref); 1304 } 1305 // Click on empty page area drops a new annotation when a drop mode is 1306 // active (toolbar buttons set the mode). Without a mode, clicking does 1307 // nothing â keeps page interactions predictable so users don't get 1308 // surprise boxes from stray clicks. 1309 pageWrap.addEventListener('click', (ev) => { 1310 if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return; 1311 if (!_pdfDropMode) return; 1312 const rect = pageWrap.getBoundingClientRect(); 1313 const xPct = ((ev.clientX - rect.left) / rect.width) * 100; 1314 const yPct = ((ev.clientY - rect.top) / rect.height) * 100; 1315 // Default sizes per kind. Center the box on the click so the value 1316 // shows up where the user pointed (text input width is wider than tall, 1317 // so centering vertically is what matters). 1318 const sizes = { 1319 text: { w: 8, h: 2.5 }, 1320 check: { w: 2.5, h: 2.5 }, 1321 signature: { w: 22, h: 6 }, 1322 }; 1323 const size = sizes[_pdfDropMode] || sizes.text; 1324 // Check stamps drop centered on the click (you point at the box you 1325 // want to tick). Text + signature anchor top-left at the click so the 1326 // first character lands exactly where the cursor was. 1327 const centered = _pdfDropMode === 'check'; 1328 const x = Math.max(0, Math.min(100 - size.w, centered ? xPct - size.w / 2 : xPct)); 1329 const y = Math.max(0, Math.min(100 - size.h, centered ? yPct - size.h / 2 : yPct)); 1330 const ann = { 1331 id: _newAnnotationId(), 1332 page: page.page, 1333 x, y, w: size.w, h: size.h, 1334 value: _pdfDropMode === 'check' ? '[ ]' : '', 1335 kind: _pdfDropMode, 1336 // For text drops, inherit the doc's last-used line spacing so the 1337 // user's "1.6" choice sticks across every new box they place. 1338 lineHeight: _pdfDropMode === 'text' ? (_pdfLastLineHeight.get(docId) || 1.3) : undefined, 1339 }; 1340 _pushPdfUndoSnapshot(docId); 1341 const built = _buildAnnotation(pageWrap, ann); 1342 annotationRefs.push(built.ref); 1343 if (_pdfDropMode === 'text') { 1344 built.ref.el.focus(); 1345 } else if (_pdfDropMode === 'signature') { 1346 // Trigger the signature picker right away â users always want to 1347 // pick the signature when they place the box. 1348 built.ref.el.click(); 1349 } 1350 _schedulePdfPaneSave(); 1351 // Mode stays armed â keep placing more until the user clicks the 1352 // toolbar button again to turn it off. 1353 }); 1354 1355 pane.appendChild(pageWrap); 1356 } 1357 _pdfPaneFieldsByDoc.set(docId, fieldRefs); 1358 } 1359 1360 // Render one annotation as a positioned wrapper with type-appropriate 1361 // content (text input / checkbox / signature picker) plus delete and drag 1362 // handles. Returns { ref } so the caller can track it for save. 1363 function _buildAnnotation(pageWrap, ann) {
1364 const kind = ann.kind || 'text'; 1365 const wrap = document.createElement('div'); 1366 wrap.className = 'pdf-annotation-wrap'; 1367 wrap.style.cssText = `position:absolute;left:${ann.x}%;top:${ann.y}%;width:${ann.w}%;height:${ann.h}%;box-sizing:border-box;z-index:2;`; 1368 wrap.dataset.annId = ann.id; 1369 wrap.dataset.annKind = kind; 1370 1371 let input; 1372 if (kind === 'check') { 1373 // Stamp-style checkmark drawn as an SVG so it scales with the box â 1374 // a glyph at fixed font-size always over- or under-fills. 1375 input = document.createElement('div'); 1376 input.style.cssText = `width:100%;height:100%;display:flex;align-items:center;justify-content:center;user-select:none;pointer-events:none;`; 1377 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>`; 1378 } else if (kind === 'signature') { 1379 input = document.createElement('div'); 1380 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));`; 1381 input.textContent = (ann.value && ann.value.startsWith('signature:')) ? '' : 'Sign here'; 1382 input.dataset.signatureId = (ann.value && ann.value.startsWith('signature:')) ? ann.value.slice(10) : ''; 1383 } else { 1384 // Multi-line text input. Browser resize disabled â we use the custom 1385 // bottom-right handle for resizing so position metadata stays in sync. 1386 // Font size uses cqh (container-query height) so the text scales with 1387 // the rendered page when the doc panel resizes â keeps annotations 1388 // visually anchored to the PDF instead of looking small/large after 1389 // a fullscreen toggle. 1390 input = document.createElement('textarea'); 1391 input.value = ann.value || ''; 1392 input.placeholder = 'Typeâ¦'; 1393 input.rows = 1; 1394 input.spellcheck = false; 1395 const lh = ann.lineHeight || 1.3; 1396 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;`; 1397 } 1398 1399 // Touch devices have no cursor, so the hover/proximity reveal never fires â 1400 // there, show the handles permanently and make them finger-sized so the 1401 // box edges are actually grabbable. 1402 const _isTouch = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches; 1403 const HS = _isTouch ? 28 : 20; // handle size (px) 1404 // Sit the handles just outside the box â inner edge meets the corner (no 1405 // gap, no overlap) so they don't cover the text you're typing but stay close. 1406 const OFF = -HS; 1407 const HIDE = _isTouch ? '' : 'none'; // initial display ('' = shown on touch) 1408 1409 // à delete button 1410 const del = document.createElement('button'); 1411 del.type = 'button'; 1412 del.textContent = 'â'; 1413 del.title = 'Delete annotation'; 1414 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;`; 1415 1416 // â° drag handle â same size as the à button. 1417 const grip = document.createElement('div'); 1418 grip.title = 'Drag to move'; 1419 grip.textContent = 'â°'; 1420 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;
1420cursor:move;font-size:11px;line-height:${HS - 2}px;text-align:center;display:${HIDE};touch-action:none;`; 1421 1422 // â resize handle â same size as the à button. 1423 const resize = document.createElement('div'); 1424 resize.title = 'Drag to resize'; 1425 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;`; 1426 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>'; 1427 1428 let menuBtn = null; 1429 if (kind === 'text') { 1430 menuBtn = document.createElement('button'); 1431 menuBtn.type = 'button'; 1432 menuBtn.textContent = 'â¦'; 1433 menuBtn.title = 'Text annotation options'; 1434 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;`; 1435 } 1436 1437 // Set handle visibility together; clicking/tapping the annotation itself 1438 // brings hidden controls back. 1439 const _setHandlesVisible = (show) => { 1440 const dismissed = wrap.dataset.controlsDismissed === '1'; 1441 const v = (show && !dismissed) ? '' : 'none'; 1442 del.style.display = v; 1443 grip.style.display = v; 1444 resize.style.display = v; 1445 if (menuBtn) menuBtn.style.display = v; 1446 }; 1447 if (!_isTouch) { 1448 wrap.addEventListener('mouseenter', () => _setHandlesVisible(true)); 1449 wrap.addEventListener('mouseleave', () => _setHandlesVisible(false)); 1450 } 1451 wrap.addEventListener('pointerdown', (ev) => { 1452 if (ev.target === del || ev.target === grip || ev.target === resize || ev.target === menuBtn) return; 1453 wrap.dataset.controlsDismissed = '0'; 1454 _setHandlesVisible(true); 1455 }); 1456 1457 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 }; 1458 1459 if (kind === 'check') { 1460 // Stamp checkmark â value is fixed, nothing to listen for. 1461 ref.value = 'â'; 1462 } else if (kind === 'signature') { 1463 const _renderSig = async (sigId) => { 1464 input.innerHTML = ''; 1465 if (!sigId) { 1466 input.dataset.signatureId = ''; 1467 input.style.background = 'color-mix(in srgb, var(--accent, var(--red)) 10%, transparent)'; 1468 input.style.border = '1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent)'; 1469 const span = document.createElement('span'); 1470 span.textContent = 'Sign here'; 1471 input.appendChild(span); 1472 return; 1473 } 1474 input.dataset.signatureId = sigId; 1475 try { 1476 if (!_sigCache.has(sigId)) { 1477 const r = await fetch(`${API_BASE}/api/signatures`); 1478 const data = await r.json(); 1479 for (const s of data.signatures || []) _sigCache.set(s.id, s.data_url); 1480 } 1481 const dataUrl = _sigCache.get(sigId); 1482 if (!dataUrl) throw new Error('not found'); 1483 const img = document.createElement('img'); 1484 img.src = dataUrl; 1485 img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 1486 input.appendChild(img); 1487 input.style.background = 'transparent'; 1488 input.style.border = '1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent)'; 1489 } catch { 1490 _renderSig(''); 1491 } 1492 }; 1493 input.addEventListener('click', async (ev) => { 1494 ev.stopPropagation(); 1495 const sig = await signatureModule.pick(); 1496 if (sig) { 1497 _pushPdfUndoSnapshot(); 1498 _sigCache.set(sig.id, sig.dataUrl); 1499 await _renderSig(sig.id); 1500 ref.value = `signature:${sig.id}`; 1501 _schedulePdfPaneSave(); 1502 } 1503 }); 1504 // Render any pre-existing signature value 1505 _renderSig(input.dataset.signatureId); 1506 } else { 1507 // Grow the wrap to fit typed content. Width grows for the longest line, 1508 // height grows for total content height. Never shrinks â user-driven 1509 // resizes (the corner handle) are preserved. 1510 let _mirror = null; 1511 const _autoGrow = () => { 1512 const pageRect = pageWrap.getBoundingClientRect(); 1513 if (!pageRect.height || !pageRect.width) return; 1514
1515 // --- Width: measure the longest line via a hidden mirror div with 1516 // the same typography as the textarea --- 1517 if (!_mirror) { 1518 _mirror = document.createElement('div'); 1519 _mirror.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font-family:inherit;padding:1px 4px;left:-9999px;top:-9999px;'; 1520 document.body.appendChild(_mirror); 1521 } 1522 const cs = window.getComputedStyle(input); 1523 _mirror.style.fontSize = cs.fontSize; 1524 _mirror.style.fontWeight = cs.fontWeight; 1525 _mirror.style.fontFamily = cs.fontFamily; 1526 _mirror.style.letterSpacing = cs.letterSpacing; 1527 let widestPx = 0; 1528 const lines = (input.value || input.placeholder || '').split('\n'); 1529 for (const line of lines) { 1530 _mirror.textContent = line || ' '; 1531 if (_mirror.offsetWidth > widestPx) widestPx = _mirror.offsetWidth; 1532 } 1533 const neededWPct = ((widestPx + 12) / pageRect.width) * 100; 1534 if (neededWPct > ref.w) { 1535 ref.w = Math.min(100 - ref.x, neededWPct); 1536 wrap.style.width = ref.w + '%'; 1537 } 1538 1539 // --- Height: same trick as before, briefly let textarea fit content --- 1540 const prev = input.style.height; 1541 input.style.height = 'auto'; 1542 const neededHpx = input.scrollHeight + 4; 1543 input.style.height = prev || '100%'; 1544 const neededHpct = (neededHpx / pageRect.height) * 100; 1545 if (neededHpct > ref.h) { 1546 ref.h = Math.min(100 - ref.y, neededHpct); 1547 wrap.style.height = ref.h + '%'; 1548 } 1549 }; 1550 input.addEventListener('input', () => { 1551 if (wrap.dataset.textUndoCaptured !== '1') { 1552 _pushPdfUndoSnapshot(); 1553 wrap.dataset.textUndoCaptured = '1'; 1554 } 1555 ref.value = input.value; 1556 _autoGrow(); 1557 _schedulePdfPaneSave(); 1558 }); 1559 input.addEventListener('change', () => { 1560 ref.value = input.value; 1561 _autoGrow(); 1562 _schedulePdfPaneSave(); 1563 }); 1564 input.addEventListener('focus', () => { 1565 _pushPdfUndoSnapshot(); 1566 wrap.dataset.textUndoCaptured = '1'; 1567 }); 1568 input.addEventListener('keydown', (ev) => { 1569 if (ev.key === 'Escape') input.blur(); 1570 }); 1571 // Initial fit in case the saved value is taller than the saved height 1572 // (e.g. line-height was bumped up after the box was placed). 1573 requestAnimationFrame(_autoGrow); 1574 // Expose so the line-spacing slider can re-fit each annotation when 1575 // the doc-wide spacing is changed. 1576 ref._autoGrow = _autoGrow; 1577 } 1578 1579 del.addEventListener('click', (ev) => { 1580 ev.stopPropagation(); 1581 _pushPdfUndoSnapshot(); 1582 _removeAnnotation(ref); 1583 }); 1584 // Drag to reposition. Coordinates are stored as percentages of the page 1585 // wrap so they survive resizing. 1586 grip.addEventListener('pointerdown', (ev) => { 1587 ev.preventDefault(); 1588 ev.stopPropagation(); 1589 _pushPdfUndoSnapshot(); 1590 try { grip.setPointerCapture(ev.pointerId); } catch (_) {} 1591 // Hide the à and resize handles while moving so they don't obscure the 1592 // box â easier to see exactly where it lands. Restored on release. 1593 del.style.display = 'none'; 1594 resize.style.display = 'none'; 1595 if (menuBtn) menuBtn.style.display = 'none'; 1596 const start = { mx: ev.clientX, my: ev.clientY, x: ref.x, y: ref.y }; 1597 const rect = pageWrap.getBoundingClientRect(); 1598 const onMove = (e) => { 1599 const dxPct = ((e.clientX - start.mx) / rect.width) * 100; 1600 const dyPct = ((e.clientY - start.my) / rect.height) * 100; 1601 ref.x = Math.max(0, Math.min(100 - ref.w, start.x + dxPct)); 1602 ref.y = Math.max(0, Math.min(100 - ref.h, start.y + dyPct)); 1603 wrap.style.left = ref.x + '%'; 1604 wrap.style.top = ref.y + '%'; 1605 }; 1606 const onUp = () => { 1607 document.removeEventListener('pointermove', onMove); 1608 document.removeEventListener('pointerup', onUp); 1609 _setHandlesVisible(true); 1610 _schedulePdfPaneSave(); 1611 }; 1612 document.addEventListener('pointermove', onMove); 1613 document.addEventListener('pointerup', onUp); 1614 }); 1615 1616 // Drag bottom-right corner to resize. Width/height stored as percentages. 1617 resize.addEventListener('pointerdown', (ev) => { 1618 ev.preventDefault(); 1619 ev.stopPropagation();
1620 _pushPdfUndoSnapshot(); 1621 try { resize.setPointerCapture(ev.pointerId); } catch (_) {} 1622 // Hide the à and move handles while resizing â clean view of the box edge. 1623 del.style.display = 'none'; 1624 grip.style.display = 'none'; 1625 if (menuBtn) menuBtn.style.display = 'none'; 1626 const start = { mx: ev.clientX, my: ev.clientY, w: ref.w, h: ref.h }; 1627 const rect = pageWrap.getBoundingClientRect(); 1628 const onMove = (e) => { 1629 const dwPct = ((e.clientX - start.mx) / rect.width) * 100; 1630 const dhPct = ((e.clientY - start.my) / rect.height) * 100; 1631 ref.w = Math.max(1, Math.min(100 - ref.x, start.w + dwPct)); 1632 ref.h = Math.max(0.8, Math.min(100 - ref.y, start.h + dhPct)); 1633 wrap.style.width = ref.w + '%'; 1634 wrap.style.height = ref.h + '%'; 1635 }; 1636 const onUp = () => { 1637 document.removeEventListener('pointermove', onMove); 1638 document.removeEventListener('pointerup', onUp); 1639 _setHandlesVisible(true); 1640 _schedulePdfPaneSave(); 1641 }; 1642 document.addEventListener('pointermove', onMove); 1643 document.addEventListener('pointerup', onUp); 1644 }); 1645 1646 // Text options menu â opened from the floating ⦠button so the spacing 1647 // controls are not always visible while typing. 1648 if (kind === 'text') { 1649 const popover = document.createElement('div'); 1650 popover.className = 'pdf-annotation-text-menu'; 1651 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;`; 1652 popover.innerHTML = ` 1653 <div style="display:flex;align-items:center;gap:6px;"> 1654 <span>Line spacing</span> 1655 <input type="range" min="1" max="3" step="0.05" value="${ann.lineHeight || 1.3}" style="width:90px;accent-color:var(--accent, var(--red));" /> 1656 <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));" /> 1657 </div> 1658 <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> 1659 `; 1660 const slider = popover.querySelector('input[type="range"]'); 1661 const valInput = popover.querySelector('.lh-val'); 1662 const todayBtn = popover.querySelector('.pdf-ann-today'); 1663 const _applyLh = (v, fromSlider) => { 1664 if (!Number.isFinite(v)) return; 1665 if (popover.dataset.lhUndoCaptured !== '1') { 1666 _pushPdfUndoSnapshot(); 1667 popover.dataset.lhUndoCaptured = '1'; 1668 } 1669 v = Math.max(0.5, Math.min(5, v)); 1670 // Apply to every text annotation in the doc so spacing stays 1671 // consistent â exports were "all over the place" because each box 1672 // could have its own lh; treat it as a doc-level setting. 1673 const allRefs = _pdfPaneAnnotationsByDoc.get(activeDocId) || []; 1674 for (const r of allRefs) { 1675 if (r.kind !== 'text') continue; 1676 r.lineHeight = v; 1677 if (r.el && r.el.style) r.el.style.lineHeight = String(v); 1678 // Spacing change can push content past the box height â fire each 1679 // ref's auto-grow so the wrap expands to fit the new line height. 1680 if (typeof r._autoGrow === 'function') r._autoGrow(); 1681 } 1682 ref.lineHeight = v; 1683 input.style.lineHeight = String(v); 1684 if (fromSlider) valInput.value = v.toFixed(2); 1685 else slider.value = String(Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), v))); 1686 _pdfLastLineHeight.set(activeDocId, v); 1687 _schedulePdfPaneSave(); 1688 }; 1689 slider.addEventListener('input', () => _applyLh(parseFloat(slider.value), true)); 1690 valInput.addEventListener('input', () => _applyLh(parseFloat(valInput.value), false)); 1691 // Reject invalid typed values on blur â snap back to the live ref value. 1692 valInput.addEventListener('blur', () => { 1693 const v = parseFloat(valInput.value); 1694 if (!Number.isFinite(v)) valInput.value = (ref.lineHeight || 1.3).toFixed(2); 1695 popover.dataset.lhUndoCaptured = '0'; 1696 }); 1697 todayBtn.addEventListener('click', () => {
1698 _pushPdfUndoSnapshot(); 1699 const d = new Date(); 1700 const dd = String(d.getDate()).padStart(2, '0'); 1701 const mm = String(d.getMonth() + 1).padStart(2, '0'); 1702 const yyyy = d.getFullYear(); 1703 const text = `${dd}/${mm}/${yyyy}`; 1704 const start = input.selectionStart ?? input.value.length; 1705 const end = input.selectionEnd ?? start; 1706 input.value = input.value.slice(0, start) + text + input.value.slice(end); 1707 const next = start + text.length; 1708 try { input.setSelectionRange(next, next); } catch (_) {} 1709 ref.value = input.value; 1710 if (typeof ref._autoGrow === 'function') ref._autoGrow(); 1711 _schedulePdfPaneSave(); 1712 input.focus({ preventScroll: true }); 1713 }); 1714 // Stop popover clicks from bubbling to pageWrap (would create new ann) 1715 popover.addEventListener('mousedown', (e) => e.stopPropagation()); 1716 popover.addEventListener('click', (e) => e.stopPropagation()); 1717 menuBtn?.addEventListener('click', (e) => { 1718 e.preventDefault(); 1719 e.stopPropagation(); 1720 popover.style.display = popover.style.display === 'flex' ? 'none' : 'flex'; 1721 }); 1722 wrap.appendChild(popover); 1723 ref.lineHeight = ann.lineHeight || 1.3; 1724 } 1725 1726 wrap.appendChild(input); 1727 wrap.appendChild(del); 1728 wrap.appendChild(grip); 1729 wrap.appendChild(resize); 1730 if (menuBtn) wrap.appendChild(menuBtn); 1731 pageWrap.appendChild(wrap); 1732 return { wrap, ref }; 1733 } 1734 1735 function _removeAnnotation(ref) { 1736 if (!ref || !ref.wrap) return; 1737 const docId = activeDocId; 1738 const refs = _pdfPaneAnnotationsByDoc.get(docId) || []; 1739 const idx = refs.indexOf(ref); 1740 if (idx >= 0) refs.splice(idx, 1); 1741 ref.wrap.remove(); 1742 _schedulePdfPaneSave(); 1743 } 1744 1745 // Prompt user for an instruction and ask the backend's VL pipeline to 1746 // propose annotations for every blank/labeled spot on the PDF. Resulting 1747 // annotations are appended into the doc's markdown and the PDF pane is 1748 // re-rendered so the user can review / edit / drag / delete each one. 1749 async function _aiFillAnnotations() { 1750 const docId = activeDocId; 1751 if (!docId) return; 1752 const doc = docs.get(docId); 1753 if (!doc) return; 1754 1755 const instruction = window.prompt( 1756 'What should the AI fill in?\n(e.g. "My name is Jane Doe, address 123 Main St, dob 1990-01-15")' 1757 ); 1758 if (!instruction || !instruction.trim()) return; 1759 1760 _setPdfSaveStatus('saving'); 1761 const btn = document.getElementById('doc-pdf-ai-fill-btn'); 1762 if (btn) { btn.disabled = true; btn.textContent = 'Thinkingâ¦'; } 1763 try { 1764 const res = await fetch(`${API_BASE}/api/document/${docId}/ai-fill-annotations`, { 1765 method: 'POST', 1766 headers: { 'Content-Type': 'application/json' }, 1767 body: JSON.stringify({ instruction: instruction.trim() }), 1768 }); 1769 if (!res.ok) { 1770 const t = await res.text().catch(() => res.statusText); 1771 throw new Error(t || res.statusText); 1772 } 1773 const data = await res.json(); 1774 const proposed = (data && data.annotations) || []; 1775 if (!proposed.length) { 1776 _setPdfSaveStatus('idle'); 1777 if (uiModule && uiModule.showToast) uiModule.showToast('AI found nothing to fill'); 1778 return; 1779 } 1780 // Merge into markdown via the same _writeAnnotations path: parse current, 1781 // append proposed (each gets a fresh id), persist, then re-render. 1782 const existing = _parseAnnotations(doc.content || ''); 1783 const combined = existing.slice(); 1784 for (const a of proposed) { 1785 combined.push({ 1786 id: _newAnnotationId(), 1787 page: parseInt(a.page, 10) || 1, 1788 x: Math.max(0, Math.min(100, parseFloat(a.x) || 0)), 1789 y: Math.max(0, Math.min(100, parseFloat(a.y) || 0)), 1790 w: Math.max(0.5, Math.min(100, parseFloat(a.w) || 22)), 1791 h: Math.max(0.3, Math.min(100, parseFloat(a.h) || 3.5)), 1792 value: String(a.value || ''), 1793 }); 1794 } 1795 const newMd = _writeAnnotations(doc.content || '', combined); 1796 doc.content = newMd; 1797 const ta = document.getElementById('doc-editor-textarea'); 1798 if (ta) ta.value = newMd; 1799 const r2 = await fetch(`${API_BASE}/api/document/${docId}`, { 1800 method: 'PUT', 1801 headers: { 'Content-Type': 'application/json' }, 1802 body: JSON.stringify({ content: newMd }), 1803 }); 1804 if (!r2.ok) { 1805 const t = await r2.text().catch(() => r2.statusText); 1806 throw new Error(t || r2.statusText); 1807 } 1808 _setPdfSaveStatus('saved');
1809 if (uiModule && uiModule.showToast) uiModule.showToast(`AI added ${proposed.length} annotations`); 1810 _renderPdfPane(); 1811 } catch (e) { 1812 console.error('AI fill failed:', e); 1813 _setPdfSaveStatus('error', `AI fill failed: ${e.message || e}`); 1814 } finally { 1815 if (btn) { btn.disabled = false; btn.textContent = 'AI fill'; } 1816 } 1817 } 1818 1819 function _schedulePdfPaneSave() { 1820 _setPdfSaveStatus('dirty'); 1821 if (_pdfPaneSaveTimer) clearTimeout(_pdfPaneSaveTimer); 1822 _pdfPaneSaveTimer = setTimeout(() => _savePdfPaneToMarkdown(), 600); 1823 } 1824 1825 function _setPdfSaveStatus(status, msg) { 1826 const pill = document.getElementById('doc-pdf-save-pill'); 1827 if (!pill) return; 1828 const palette = { 1829 idle: { txt: '', bg: 'transparent', fg: 'transparent' }, 1830 dirty: { txt: 'Editingâ¦', bg: 'var(--panel)', fg: 'var(--fg)' }, 1831 saving: { txt: 'Savingâ¦', bg: 'var(--panel)', fg: 'var(--fg)' }, 1832 saved: { txt: 'Saved', bg: 'rgba(34,197,94,0.85)', fg: '#fff' }, 1833 error: { txt: msg || 'Save failed', bg: 'var(--red)', fg: 'var(--bg)' }, 1834 }; 1835 const p = palette[status] || palette.idle; 1836 pill.textContent = p.txt; 1837 pill.style.background = p.bg; 1838 pill.style.color = p.fg; 1839 pill.style.display = p.txt ? '' : 'none'; 1840 if (status === 'saved') { 1841 setTimeout(() => { 1842 if (pill.textContent === 'Saved') _setPdfSaveStatus('idle'); 1843 }, 1200); 1844 } 1845 } 1846 1847 async function _savePdfPaneToMarkdown(opts = {}) { 1848 _pdfPaneSaveTimer = null; 1849 const docId = activeDocId; 1850 const fields = _pdfPaneFieldsByDoc.get(docId) || []; 1851 const annotations = _pdfPaneAnnotationsByDoc.get(docId) || []; 1852 if (!docId || (!fields.length && !annotations.length)) return false; 1853 const doc = docs.get(docId); 1854 if (!doc) return false; 1855 1856 let md = doc.content || ''; 1857 let changed = 0; 1858 for (const ref of fields) { 1859 // Server-side render percent-encodes everything outside [A-Za-z0-9_.-]. 1860 // Match that exactly so spaces / newlines / parens / commas / `?` in 1861 // raw AcroForm names don't break the regex. 1862 const encName = _encodeFieldName(ref.name); 1863 const re = new RegExp( 1864 `^(\\s*-\\s+)(.*?)(\\s*<!--\\s*field=${encName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\s+type=\\w+\\s*-->\\s*)$`, 1865 'm' 1866 ); 1867 const m = md.match(re); 1868 if (!m) continue; 1869 const body = m[2]; 1870 let newBody = body; 1871 if (ref.type === 'checkbox') { 1872 const mark = ref.el.checked ? '[x]' : '[ ]'; 1873 newBody = body.replace(/^\s*\[[ xX]\]/, mark); 1874 } else if (ref.type === 'choice') { 1875 const v = ref.el.value || '_(not selected)_'; 1876 newBody = body.replace(/(\][\s]*:[ ]*).*$/, `$1${v}`); 1877 } else if (ref.type === 'signature') { 1878 const sid = ref.el.dataset.signatureId || ''; 1879 const v = sid ? `signature:${sid}` : '_(unsigned)_'; 1880 newBody = body.replace(/(:\*\*[ ]*).*$/, `$1${v}`); 1881 } else { 1882 const v = ref.el.value === '' ? '_(empty)_' : ref.el.value; 1883 newBody = body.replace(/(:\*\*[ ]*).*$/, `$1${v}`); 1884 } 1885 if (newBody !== body) { 1886 md = md.replace(re, `${m[1]}${newBody}${m[3]}`); 1887 changed++; 1888 } 1889 } 1890 // Rewrite the freeform-annotations section from the live ref set so 1891 // creates / edits / moves / deletes all persist in one shot. 1892 md = _writeAnnotations(md, annotations.map(a => { 1893 let value = ''; 1894 if (a.kind === 'check') { 1895 value = 'â'; 1896 } else if (a.kind === 'signature') { 1897 const sid = a.el && a.el.dataset && a.el.dataset.signatureId; 1898 value = sid ? `signature:${sid}` : ''; 1899 } else { 1900 value = (a.el && typeof a.el.value === 'string') ? a.el.value : ''; 1901 } 1902 return { 1903 id: a.id, page: a.page, x: a.x, y: a.y, w: a.w, h: a.h, 1904 kind: a.kind || 'text', 1905 lineHeight: a.lineHeight || 1.3, 1906 value, 1907 }; 1908 })); 1909 if (md === doc.content) { 1910 _setPdfSaveStatus('idle'); 1911 return true; 1912 } 1913 doc.content = md; 1914 const ta = document.getElementById('doc-editor-textarea'); 1915 if (ta) ta.value = md; 1916 _setPdfSaveStatus('saving'); 1917 try { 1918 const res = await fetch(`${API_BASE}/api/document/${docId}`, { 1919 method: 'PUT', 1920 headers: { 'Content-Type': 'application/json' }, 1921 body: JSON.stringify({ content: md }), 1922 keepalive: !!opts.keepalive, 1923 }); 1924 if (!res.ok) { 1925 const t = await res.text().catch(() => res.statusText); 1926 _setPdfSaveStatus('error', `Save failed: ${res.status}`); 1927 console.warn('PDF-pane save HTTP error:', res.status, t); 1928 return false;
1929 } 1930 _setPdfSaveStatus('saved'); 1931 return true; 1932 } catch (e) { 1933 _setPdfSaveStatus('error', e.message || 'Save failed'); 1934 console.warn('PDF-pane save failed:', e); 1935 return false; 1936 } 1937 } 1938 1939 // Flush any pending debounced save before navigating away 1940 window.addEventListener('beforeunload', () => { 1941 if (_pdfPaneSaveTimer) { 1942 clearTimeout(_pdfPaneSaveTimer); 1943 _savePdfPaneToMarkdown({ keepalive: true }); 1944 } 1945 }); 1946 1947 async function _refreshPdfPreviewIframe() { 1948 // Re-render the pane from the backend's current parsed values. 1949 // Flush any debounced user edit first so we don't clobber it. 1950 const pane = document.getElementById('doc-pdf-view'); 1951 if (!pane || !activeDocId) return; 1952 if (pane.style.display === 'none') return; 1953 if (_pdfPaneSaveTimer) { 1954 clearTimeout(_pdfPaneSaveTimer); 1955 await _savePdfPaneToMarkdown(); 1956 } 1957 _renderPdfPane(); 1958 } 1959 1960 async function _setPdfViewActive(active) { 1961 const pane = document.getElementById('doc-pdf-view'); 1962 const wrap = document.getElementById('doc-editor-wrap'); 1963 const btn = document.getElementById('doc-pdf-view-btn'); 1964 if (!pane || !wrap) return; 1965 if (active) { 1966 _pdfViewState.set(activeDocId, true); 1967 wrap.style.display = 'none'; 1968 pane.style.display = ''; 1969 _renderPdfPane(); 1970 btn?.classList.add('active'); 1971 } else { 1972 // Flush any pending debounced edit before tearing down the field refs. 1973 if (_pdfPaneSaveTimer) { 1974 clearTimeout(_pdfPaneSaveTimer); 1975 await _savePdfPaneToMarkdown(); 1976 } 1977 _pdfViewState.set(activeDocId, false); 1978 pane.style.display = 'none'; 1979 // Preserve the save pill across renders 1980 const savedPill = document.getElementById('doc-pdf-save-pill'); 1981 pane.innerHTML = ''; 1982 if (savedPill) pane.appendChild(savedPill); 1983 _pdfPaneFieldsByDoc.delete(activeDocId); 1984 _pdfPaneAnnotationsByDoc.delete(activeDocId); 1985 wrap.style.display = ''; 1986 btn?.classList.remove('active'); 1987 } 1988 } 1989 1990 // Hide the top header bar when nothing in it is visible. With Undo + the type 1991 // picker moved to the footer, a plain doc on mobile would otherwise show an 1992 // empty bar (the "second footer"). Reflow-free (reads inline display only) so 1993 // it's safe to call from _syncHeaderActions on every stream patch. On desktop 1994 // the bar always shows (it still hosts Fullscreen + the version badge); on 1995 // mobile it shows only when a contextual control is active. 1996 function _syncHeaderBarVisibility() { 1997 const hdr = document.getElementById('doc-editor-actions'); 1998 if (!hdr) return; 1999 // Email docs hide the whole header (they use their own send footer) â never 2000 // resurrect it here. 2001 if (docs.get(activeDocId)?.language === 'email') { hdr.style.display = 'none'; return; } 2002 const vis = (id) => { 2003 const e = document.getElementById(id); 2004 if (!e || !e.parentElement) return false; 2005 // Only count items still LIVING in the header itself â the runtime 2006 // rearrangement (~line 3217) moves several buttons into the footer, and 2007 // we don't want a button parked elsewhere to keep this top row alive. 2008 if (!hdr.contains(e)) return false; 2009 return e.style.display !== 'none'; 2010 }; 2011 // Hide the whole header when nothing visible lives here anymore. Without 2012 // this every desktop view rendered an empty doc-editor-header above the 2013 // real action footer â a duplicate row. 2014 const visible = vis('doc-stream-indicator') 2015 || vis('doc-version-badge') 2016 || vis('doc-export-pdf-btn') 2017 || vis('doc-pdf-view-btn'); 2018 hdr.style.display = visible ? '' : 'none'; 2019 } 2020 2021 function _syncHeaderActions() { 2022 const actionBtn = document.getElementById('doc-header-preview-btn'); 2023 const exportBtn = document.getElementById('doc-export-pdf-btn'); 2024 const pdfViewBtn = document.getElementById('doc-pdf-view-btn'); 2025 const pdfPane = document.getElementById('doc-pdf-view'); 2026 const langSelect = document.getElementById('doc-language-select'); 2027 const live = document.getElementById('doc-editor-textarea')?.value 2028 || docs.get(activeDocId)?.content 2029 || ''; 2030 const isForm = _isFormBackedDoc(live); 2031 // Footer main button: for a doc opened from an email attachment, morph the 2032 // Copy button into "Reply" (send the filled file back to the sender via the 2033 // signed-reply flow). Otherwise it's the normal Copy action. The click 2034 // handler branches on data-mode. 2035 const _copyBtn = document.getElementById('doc-footer-copy-btn'); 2036 if (_copyBtn) { 2037 const _ad = docs.get(activeDocId); 2038 const _replyable = !!(_ad && _ad.sourceEmailUid && _ad.sourceEmailFolder); 2039 if (_replyable && _copyBtn.dataset.mode !== 'reply') { 2040 _copyBtn.dataset.mode = 'reply'; 2041 _copyBtn.title = 'Reply to the sender with this filled file attached'; 2042 _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'; 2043 } else if (!_replyable && _copyBtn.dataset.mode !== 'copy') { 2044 _copyBtn.dataset.mode = 'copy'; 2045 _copyBtn.title = 'Copy document'; 2046 _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'; 2047 } 2048 } 2049 // Standalone Export PDF / PDF-toggle icon buttons are retired â for a 2050 // form-backed doc the language selector itself toggles between 2051 // "pdf" (rendered view) and "markdown" (source view). 2052 if (exportBtn) exportBtn.style.display = 'none'; 2053 if (pdfViewBtn) pdfViewBtn.style.display = 'none'; 2054 if (true) { 2055 const explicit = _pdfViewState.get(activeDocId); 2056 const active = isForm && explicit !== false; 2057 // Sync the language select's displayed value to the current view. 2058 if (isForm && langSelect) { 2059 const want = active ? 'pdf' : 'markdown'; 2060 if (langSelect.value !== want) langSelect.value = want; 2061 } 2062 if (pdfPane) { 2063 if (active) { 2064 if (pdfPane.style.display === 'none') { 2065 const wrap = document.getElementById('doc-editor-wrap'); 2066 if (wrap) wrap.style.display = 'none'; 2067 pdfPane.style.display = ''; 2068 _renderPdfPane(); 2069 } 2070 } else if (pdfPane.style.display !== 'none') { 2071 pdfPane.style.display = 'none'; 2072 pdfPane.innerHTML = ''; 2073 const wrap = document.getElementById('doc-editor-wrap'); 2074 if (wrap) wrap.style.display = ''; 2075 } 2076 } 2077 } 2078 if (!actionBtn) return; 2079 2080 const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); 2081 const canPreview = ['markdown', 'csv'].includes(lang) || _isRenderLang(lang); 2082 const canRun = ['javascript', 'js', 'python', 'py', 'bash', 'sh', 'shell', 'zsh'].includes(lang); 2083 2084 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>'; 2085 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>'; 2086 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>'; 2087 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>'; 2088 2089 // Check active states 2090 const _mdPreview = document.getElementById('doc-md-preview'); 2091 const _csvPreview = document.getElementById('doc-csv-preview'); 2092 const _htmlPreview = document.getElementById('doc-html-preview'); 2093 const _outputPanel = document.getElementById('doc-run-output'); 2094 const _mdActive = _mdPreview && _mdPreview.style.display !== 'none'; 2095 const _csvActive = _csvPreview && _csvPreview.style.display !== 'none'; 2096 const _htmlActive = _htmlPreview && _htmlPreview.style.display !== 'none'; 2097 const _outputActive = _outputPanel && _outputPanel.style.display !== 'none'; 2098 2099 let show = false;
2100 actionBtn.classList.remove('active'); 2101 2102 // The markdown Edit/Preview toggle is a two-icon switch; other modes use 2103 // the single dynamic preview button. 2104 const mdToggle = document.getElementById('doc-md-view-toggle'); 2105 if (mdToggle) mdToggle.style.display = (lang === 'markdown') ? 'inline-flex' : 'none'; 2106 const renderToggle = document.getElementById('doc-render-view-toggle'); 2107 if (renderToggle) { 2108 renderToggle.style.display = _hasViewToggle(lang) ? 'inline-flex' : 'none'; 2109 // Swap the "run" side's icon to match what the language actually does: 2110 // CSV â 4-quadrant grid (table view) 2111 // HTML / SVG / XML â eye (rendered preview) 2112 // Python / JS / TS / bash â play triangle (run code) 2113 const runBtn = renderToggle.querySelector('[data-renderview="run"]'); 2114 if (runBtn) { 2115 let icon, title; 2116 if (lang === 'csv') { 2117 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>'; 2118 title = 'Table view'; 2119 } else if (_isRenderLang(lang)) { 2120 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>'; 2121 title = 'Preview'; 2122 } else { 2123 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>'; 2124 title = 'Run'; 2125 } 2126 if (runBtn.dataset.lastIcon !== lang) { 2127 runBtn.innerHTML = icon; 2128 runBtn.title = title; 2129 runBtn.dataset.lastIcon = lang; 2130 } 2131 } 2132 // Swap the "code" side's icon too â CSV's "code" really means "edit 2133 // the underlying spreadsheet text", so a pencil reads better than the 2134 // </> brackets used for actual code. 2135 const codeBtn = renderToggle.querySelector('[data-renderview="code"]'); 2136 if (codeBtn) { 2137 const codeIco = (lang === 'csv') 2138 ? '<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>' 2139 : '<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>'; 2140 const codeTitle = (lang === 'csv') ? 'Edit' : 'Edit code'; 2141 if (codeBtn.dataset.lastIcon !== lang) { 2142 codeBtn.innerHTML = codeIco; 2143 codeBtn.title = codeTitle; 2144 codeBtn.dataset.lastIcon = lang; 2145 } 2146 } 2147 // Reflect which side is currently active so the toggle shows the same 2148 // visual feedback markdown's Edit/Preview switch does (background tint 2149 // + the "punch" pop animation from .md-view-toggle .md-view-opt.active). 2150 // For CSV the run side = table view; for HTML/SVG/XML = iframe preview; 2151 // for runnable langs = output panel open. 2152 let _viewActive = false; 2153 if (lang === 'csv') _viewActive = _csvActive; 2154 else if (_isRenderLang(lang)) _viewActive = _htmlActive; 2155 else _viewActive = _outputActive; 2156 const _codeBtn2 = renderToggle.querySelector('[data-renderview="code"]'); 2157 const _runBtn2 = renderToggle.querySelector('[data-renderview="run"]'); 2158 _codeBtn2?.classList.toggle('active', !_viewActive); 2159 _runBtn2?.classList.toggle('active', _viewActive); 2160 } 2161 2162 if (lang === 'markdown') { 2163 show = false; 2164 if (mdToggle) { 2165 mdToggle.querySelector('[data-mdview="edit"]')?.classList.toggle('active', !_mdActive); 2166 mdToggle.querySelector('[data-mdview="preview"]')?.classList.toggle('active', _mdActive); 2167 } 2168 } else if (lang === 'csv') { 2169 show = true; 2170 actionBtn.innerHTML = _csvActive ? _penIco : '<span style="font-size:12px;font-weight:600;">â</span>'; 2171 actionBtn.title = _csvActive ? 'Edit' : 'Table View'; 2172 if (_csvActive) actionBtn.classList.add('active'); 2173 } else if (_isRenderLang(lang)) { 2174 // SVG/HTML/XML use the segmented Code </> | Run â¶ light-switch toggle 2175 // (like markdown's edit/preview switch) instead of the single button. 2176 show = false;
2177 if (renderToggle) { 2178 renderToggle.querySelector('[data-renderview="code"]')?.classList.toggle('active', !_htmlActive); 2179 renderToggle.querySelector('[data-renderview="run"]')?.classList.toggle('active', _htmlActive); 2180 } 2181 } else if (canRun) { 2182 show = true; 2183 actionBtn.innerHTML = _outputActive ? _codeIco : _playIco; 2184 actionBtn.title = _outputActive ? 'Hide output' : 'Run'; 2185 if (_outputActive) actionBtn.classList.add('active'); 2186 } 2187 2188 // The unified segmented Code/Run-or-View toggle (`#doc-render-view-toggle`) 2189 // covers CSV / Python / JS / bash / HTML / SVG / XML. When it's shown, 2190 // suppress the single morph button to avoid two redundant controls. 2191 if (_hasViewToggle(lang)) show = false; 2192 actionBtn.style.display = show ? '' : 'none'; 2193 2194 // Now that the contextual buttons' visibility is settled, collapse the bar 2195 // if it ended up empty (the common plain-doc-on-mobile case). 2196 _syncHeaderBarVisibility(); 2197 } 2198 2199 // ââ Email document type helpers ââ 2200 2201 function _parseEmailHeader(content) { 2202 const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: content || '' }; 2203 if (!content) return empty; 2204 const parts = content.split(/\n---\n/); 2205 if (parts.length < 2) return empty; 2206 const header = parts[0]; 2207 const body = parts.slice(1).join('\n---\n'); 2208 const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: body }; 2209 for (const line of header.split('\n')) { 2210 const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):\s*(.*)$/i); 2211 if (m) { 2212 let key = m[1].toLowerCase(); 2213 if (key === 'in-reply-to') key = 'inReplyTo'; 2214 else if (key === 'x-source-uid') key = 'sourceUid'; 2215 else if (key === 'x-source-folder') key = 'sourceFolder'; 2216 else if (key === 'x-attachments') { 2217 fields.attachments = m[2].trim().split('|').map(a => { 2218 const [index, filename, size] = a.split(':'); 2219 return { index: parseInt(index), filename, size: parseInt(size) }; 2220 }); 2221 continue; 2222 } 2223 fields[key] = m[2].trim(); 2224 } 2225 } 2226 return fields; 2227 } 2228 2229 function _buildEmailContent(to, subject, inReplyTo, references, body, sourceUid, sourceFolder, cc, bcc) { 2230 let header = `To: ${to}`; 2231 if (cc) header += `\nCc: ${cc}`; 2232 if (bcc) header += `\nBcc: ${bcc}`; 2233 header += `\nSubject: ${subject}`; 2234 if (inReplyTo) header += `\nIn-Reply-To: ${inReplyTo}`; 2235 if (references) header += `\nReferences: ${references}`; 2236 if (sourceUid) header += `\nX-Source-UID: ${sourceUid}`; 2237 if (sourceFolder) header += `\nX-Source-Folder: ${sourceFolder}`; 2238 return header + '\n---\n' + body; 2239 } 2240 2241 // ââ WYSIWYG email body helpers ââ 2242 function _emailBodyToHtml(text) { 2243 const t = (text || '').trim(); 2244 if (!t) return ''; 2245 // If it already contains a formatting/structural HTML tag, it's a saved 2246 // WYSIWYG body â use it verbatim. (Checking a leading '<' isn't enough: a 2247 // rich body often starts with plain text, e.g. "Hi <b>there</b>".) 2248 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; 2249 // Email body: keep author-typed `:shortcode:` text literal. Issue #345 2250 // (shortcode â emoji) is scoped to chat; do not rewrite colons in mail. 2251 try { return markdownModule.mdToHtml(text, { shortcodes: false }); } 2252 catch (_) { 2253 const d = document.createElement('div'); d.textContent = text; 2254 return d.innerHTML.replace(/\n/g, '<br>'); 2255 } 2256 } 2257 // Mirror the rich body's plain text into the hidden textarea so the existing 2258 // send / draft / change-detection plumbing (which reads the textarea) stays 2259 // valid. The rich body's HTML is read separately on send (body_html). 2260 function _syncEmailRichbody(rich) { 2261 const ta = document.getElementById('doc-editor-textarea'); 2262 if (!ta) return; 2263 ta.value = rich.innerText; 2264 ta.dispatchEvent(new Event('input', { bubbles: true })); 2265 } 2266 function _wireEmailRichbody(rich) { 2267 if (rich._wired) { _syncEmailRichbody(rich); return; } 2268 rich._wired = true; 2269 rich.addEventListener('input', () => _syncEmailRichbody(rich)); 2270 // Highlight toolbar buttons (B / I / S, headings, lists) when the caret 2271 // sits inside formatted text. queryCommandState reflects the live 2272 // selection â we just translate that into .is-active classes the CSS 2273 // already understands. 2274 const syncActive = () => { 2275 if (!rich.isConnected || rich.style.display === 'none') return;
2276 // Only sync when focus is inside the rich body â otherwise selection 2277 // outside it (e.g. clicking the toolbar itself) gives misleading state. 2278 if (!rich.contains(document.activeElement) && document.activeElement !== rich) return; 2279 const tb = document.getElementById('doc-md-toolbar'); 2280 if (!tb) return; 2281 const set = (sel, on) => { const b = tb.querySelector(sel); if (b) b.classList.toggle('is-active', !!on); }; 2282 try { 2283 set('[data-md="bold"]', document.queryCommandState('bold')); 2284 set('[data-md="italic"]', document.queryCommandState('italic')); 2285 set('[data-md="strike"]', document.queryCommandState('strikeThrough')); 2286 } catch (_) {} 2287 // Block-level: heading / list dropdown toggles read their active state 2288 // from the current block tag. 2289 const cur = _currentBlockTag(rich); 2290 const hBtn = tb.querySelector('[data-dd="heading"]'); 2291 if (hBtn) hBtn.classList.toggle('is-active', cur === 'h1' || cur === 'h2' || cur === 'h3'); 2292 try { 2293 const inList = document.queryCommandState('insertOrderedList') || document.queryCommandState('insertUnorderedList'); 2294 const lBtn = tb.querySelector('[data-dd="list"]'); 2295 if (lBtn) lBtn.classList.toggle('is-active', !!inList); 2296 } catch (_) {} 2297 }; 2298 rich.addEventListener('keyup', syncActive); 2299 rich.addEventListener('mouseup', syncActive); 2300 rich.addEventListener('focus', syncActive); 2301 rich.addEventListener('input', syncActive); 2302 // selectionchange fires on the document; filter to selections inside rich. 2303 document.addEventListener('selectionchange', () => { 2304 const sel = window.getSelection(); 2305 if (sel && sel.rangeCount && rich.contains(sel.anchorNode)) syncActive(); 2306 }); 2307 rich._syncActive = syncActive; 2308 } 2309 function _emailRichbodyActive() { 2310 const r = document.getElementById('doc-email-richbody'); 2311 return r && r.style.display !== 'none' ? r : null; 2312 } 2313 2314 function _captureEmailBodyFocusState() { 2315 const rich = _emailRichbodyActive(); 2316 const ta = document.getElementById('doc-editor-textarea'); 2317 const active = document.activeElement; 2318 if (rich && (active === rich || rich.contains(active))) { 2319 const sel = window.getSelection(); 2320 const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null; 2321 return { 2322 type: 'rich', 2323 range: range && rich.contains(range.commonAncestorContainer) ? range.cloneRange() : null, 2324 }; 2325 } 2326 if (ta && active === ta) { 2327 return { 2328 type: 'textarea', 2329 start: ta.selectionStart, 2330 end: ta.selectionEnd, 2331 }; 2332 } 2333 return null; 2334 } 2335 2336 function _restoreEmailBodyFocusState(state) { 2337 if (!state) return; 2338 requestAnimationFrame(() => { 2339 if (state.type === 'rich') { 2340 const rich = _emailRichbodyActive(); 2341 if (!rich) return; 2342 rich.focus({ preventScroll: true }); 2343 if (state.range) { 2344 const sel = window.getSelection(); 2345 if (sel) { 2346 sel.removeAllRanges(); 2347 sel.addRange(state.range); 2348 } 2349 } 2350 } else if (state.type === 'textarea') { 2351 const ta = document.getElementById('doc-editor-textarea'); 2352 if (!ta) return; 2353 ta.focus({ preventScroll: true }); 2354 if (Number.isFinite(state.start) && Number.isFinite(state.end)) { 2355 try { ta.setSelectionRange(state.start, state.end); } catch (_) {} 2356 } 2357 } 2358 }); 2359 } 2360 2361 function _stripEmailReplyQuoteText(text) { 2362 const original = String(text || ''); 2363 if (!original) return { body: '', stripped: false }; 2364 const lines = original.split('\n'); 2365 const quoteIdx = lines.findIndex(line => 2366 /^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim()) 2367 || /^On .+ wrote:\s*$/i.test(line.trim()) 2368 ); 2369 if (quoteIdx <= 0) return { body: original.trim(), stripped: false }; 2370 const body = lines.slice(0, quoteIdx).join('\n').trim(); 2371 return { body, stripped: !!body }; 2372 } 2373 2374 function _emailReplyOwnText(text) { 2375 return _stripEmailReplyQuoteText(text).body; 2376 } 2377 2378 function _setEmailBodyText(textarea, value) { 2379 if (!textarea) return; 2380 textarea.value = value || '';
2381 syncHighlighting(); 2382 const rich = _emailRichbodyActive(); 2383 if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value); 2384 } 2385 2386 async function _streamEmailBodyText(textarea, value) { 2387 if (!textarea) return; 2388 const finalText = String(value || ''); 2389 const maxFrames = 90; 2390 const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames)); 2391 textarea.value = ''; 2392 const rich = _emailRichbodyActive(); 2393 if (rich) rich.innerHTML = ''; 2394 for (let i = 0; i < finalText.length; i += chunk) { 2395 const next = finalText.slice(0, i + chunk); 2396 textarea.value = next; 2397 if (rich) rich.innerHTML = _emailBodyToHtml(next); 2398 await new Promise(resolve => requestAnimationFrame(resolve)); 2399 } 2400 _setEmailBodyText(textarea, finalText); 2401 } 2402 2403 function _focusEmailBodyEnd() { 2404 const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea'); 2405 if (!target) return; 2406 target.focus(); 2407 if (target.isContentEditable) { 2408 const range = document.createRange(); 2409 range.selectNodeContents(target); 2410 range.collapse(false); 2411 const sel = window.getSelection(); 2412 if (sel) { 2413 sel.removeAllRanges(); 2414 sel.addRange(range); 2415 } 2416 } else if (typeof target.setSelectionRange === 'function') { 2417 const len = target.value.length; 2418 target.setSelectionRange(len, len); 2419 } 2420 } 2421 2422 function _syncEmailHeaderSummary() { 2423 const to = document.getElementById('doc-email-to')?.value?.trim() || 'No recipient'; 2424 const subject = document.getElementById('doc-email-subject')?.value?.trim() || 'No subject'; 2425 const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; 2426 const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; 2427 const summary = document.getElementById('doc-email-collapse-summary'); 2428 if (!summary) return; 2429 const extras = []; 2430 if (cc) extras.push('Cc'); 2431 if (bcc) extras.push('Bcc'); 2432 summary.textContent = `${to} · ${subject}${extras.length ? ` · ${extras.join('/')}` : ''}`; 2433 summary.title = summary.textContent; 2434 } 2435 2436 function _setEmailHeaderCollapsed(collapsed, { manual = true } = {}) { 2437 const header = document.getElementById('doc-email-header'); 2438 const btn = document.getElementById('doc-email-collapse-btn'); 2439 if (!header) return; 2440 if (window.innerWidth > 768) collapsed = false; 2441 header.classList.toggle('doc-email-header-collapsed', !!collapsed); 2442 if (btn) { 2443 btn.setAttribute('aria-expanded', String(!collapsed)); 2444 btn.title = collapsed ? 'Show email fields' : 'Hide email fields'; 2445 } 2446 const doc = activeDocId && docs.get(activeDocId); 2447 if (doc && manual) doc._emailHeaderCollapsed = !!collapsed; 2448 if (manual && !collapsed) _emailHeaderManualExpandUntil = Date.now() + 1400; 2449 _syncEmailHeaderSummary(); 2450 } 2451 2452 function _shouldAutoCollapseEmailHeader() { 2453 return window.innerWidth <= 768; 2454 } 2455 2456 function _maybeAutoCollapseEmailHeader() { 2457 const doc = activeDocId && docs.get(activeDocId); 2458 if (!doc || doc.language !== 'email') return; 2459 if (Date.now() < _emailHeaderManualExpandUntil) return; 2460 if (document.activeElement?.closest?.('#doc-email-fields')) return; 2461 if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false }); 2462 } 2463 2464 function _showEmailFields(doc) { 2465 const emailHeader = document.getElementById('doc-email-header'); 2466 const emailActions = document.getElementById('doc-email-actions'); 2467 // Show MD toolbar for email too (B, I, etc.) 2468 const mdToolbar = document.getElementById('doc-md-toolbar'); 2469 if (mdToolbar) { 2470 mdToolbar.style.display = ''; 2471 if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); 2472 } 2473 // Hide toolbar items that have no clean WYSIWYG equivalent in email (Code). 2474 document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; }); 2475 if (emailHeader) emailHeader.style.display = ''; 2476 if (emailActions) emailActions.style.display = ''; 2477 // Emails have their own complete footer (Close / More / Send), so hide the 2478 // generic documents action bar AND the generic bottom footer. The TYPE 2479 // picker is the exception â relocate it into the email footer so the 2480 // type-switching affordance is in the same footer slot across all docs. 2481 const docActions = document.getElementById('doc-editor-actions'); 2482 if (docActions) docActions.style.display = 'none'; 2483 const docFooter = document.getElementById('doc-actions-footer'); 2484 if (docFooter) docFooter.style.display = 'none'; 2485 if (emailActions) { 2486 const _lang = document.getElementById('doc-language-select'); 2487 const _sendSplit = emailActions.querySelector('.email-send-split'); 2488 if (_lang && _sendSplit) emailActions.insertBefore(_lang, _sendSplit); 2489 } 2490 // Colored system-emoji font for email compose 2491 document.getElementById('doc-editor-textarea')?.classList.add('email-mode'); 2492 document.getElementById('doc-editor-code')?.classList.add('email-mode'); 2493 document.getElementById('doc-editor-highlight')?.classList.add('email-mode'); 2494 const fields = _parseEmailHeader(doc.content || ''); 2495 const toInput = document.getElementById('doc-email-to'); 2496 const subjectInput = document.getElementById('doc-email-subject'); 2497 const inReplyTo = document.getElementById('doc-email-in-reply-to'); 2498 const refs = document.getElementById('doc-email-references'); 2499 const textarea = document.getElementById('doc-editor-textarea'); 2500 if (toInput) toInput.value = fields.to; 2501 if (subjectInput) subjectInput.value = fields.subject; 2502 _setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false }); 2503 if (subjectInput && !subjectInput._emailTabBodyBound) { 2504 subjectInput._emailTabBodyBound = true; 2505 subjectInput.addEventListener('keydown', (e) => { 2506 if (e.key === 'Tab' && !e.shiftKey) { 2507 e.preventDefault(); 2508 _focusEmailBodyEnd(); 2509 } 2510 }); 2511 } 2512 if (inReplyTo) inReplyTo.value = fields.inReplyTo; 2513 if (refs) refs.value = fields.references; 2514 const sourceUid = document.getElementById('doc-email-source-uid'); 2515 const sourceFolder = document.getElementById('doc-email-source-folder'); 2516 if (sourceUid) sourceUid.value = fields.sourceUid || ''; 2517 if (sourceFolder) sourceFolder.value = fields.sourceFolder || ''; 2518 // Show/hide unread button only if we have a source UID (came from inbox) 2519 const unreadBtn = document.getElementById('doc-email-unread-btn'); 2520 if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none'; 2521 // Render attachment chips 2522 const attDiv = document.getElementById('doc-email-attachments'); 2523 if (attDiv) { 2524 attDiv.innerHTML = ''; 2525 if (fields.attachments && fields.attachments.length > 0 && fields.sourceUid) { 2526 attDiv.style.display = ''; 2527 for (const att of fields.attachments) { 2528 const isPdf = (att.filename || '').toLowerCase().endsWith('.pdf'); 2529 const sizeKb = att.size > 0 ? `${Math.round(att.size / 1024)} KB` : ''; 2530 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>`;
2531 // Helper: swap chip content for a whirlpool spinner while busy. 2532 const _withSpinner = async (chip, fn) => { 2533 if (chip.dataset.loading === '1') return; 2534 chip.dataset.loading = '1'; 2535 const orig = chip.innerHTML; 2536 chip.innerHTML = ''; 2537 const sp = spinnerModule.createWhirlpool(14); 2538 sp.style.marginRight = '6px'; 2539 chip.appendChild(sp); 2540 const lbl = document.createElement('span'); 2541 lbl.textContent = att.filename; 2542 chip.appendChild(lbl); 2543 try { await fn(); } 2544 finally { chip.dataset.loading = ''; chip.innerHTML = orig; } 2545 }; 2546 if (isPdf) { 2547 // PDF: open in the in-app PDF viewer as a new doc tab 2548 const chip = document.createElement('button'); 2549 chip.type = 'button'; 2550 chip.className = 'email-attachment-chip email-attachment-chip-pdf'; 2551 // Full filename on hover â chip ellipsis-truncates long names. 2552 chip.title = att.filename; 2553 chip.innerHTML = chipHtml; 2554 chip.addEventListener('click', () => _withSpinner(chip, async () => { 2555 try { 2556 const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX'); 2557 const res = await fetch(`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`, { method: 'POST' }); 2558 const data = await res.json(); 2559 if (data.doc_id) { 2560 await loadDocument(data.doc_id); 2561 } else if (uiModule) { 2562 uiModule.showError(data.error || 'Failed to open PDF'); 2563 window.open(`${API_BASE}/api/email/attachment/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`, '_blank'); 2564 } 2565 } catch (e) { 2566 console.error('Open PDF attachment failed:', e); 2567 if (uiModule) uiModule.showError('Failed to open PDF'); 2568 } 2569 })); 2570 attDiv.appendChild(chip); 2571 } else { 2572 // Non-PDF: download via fetch+blob+anchor â browser-native download 2573 // with target=_blank was unreliable in some browsers (the click did 2574 // nothing). The blob path forces a real Save dialog every time. 2575 const chip = document.createElement('button'); 2576 chip.type = 'button'; 2577 chip.className = 'email-attachment-chip'; 2578 // Full filename on hover for the chip ellipsis-truncated label. 2579 chip.title = `Download ${att.filename}`; 2580 chip.innerHTML = chipHtml; 2581 chip.addEventListener('click', () => _withSpinner(chip, async () => { 2582 try { 2583 const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX'); 2584 const res = await fetch(`${API_BASE}/api/email/attachment/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`); 2585 if (!res.ok) throw new Error(`HTTP ${res.status}`); 2586 const blob = await res.blob(); 2587 const url = URL.createObjectURL(blob); 2588 const a = document.createElement('a'); 2589 a.href = url; a.download = att.filename; 2590 document.body.appendChild(a); 2591 a.click(); 2592 a.remove(); 2593 setTimeout(() => URL.revokeObjectURL(url), 1000); 2594 } catch (e) { 2595 console.error('Download attachment failed:', e); 2596 if (uiModule) uiModule.showError('Download failed: ' + e.message); 2597 } 2598 })); 2599 attDiv.appendChild(chip); 2600 } 2601 } 2602 } else { 2603 attDiv.style.display = 'none'; 2604 } 2605 } 2606 if (textarea) { 2607 textarea.value = fields.body; 2608 // Store original body for change detection on close 2609 if (doc) doc._originalBody = fields.body; 2610 syncHighlighting(); 2611 } 2612 // WYSIWYG: swap the source editor for the rich body and render the markdown. 2613 // The textarea above stays as the plain-text mirror (kept in sync below) so 2614 // send / draft / change-detection still read it. 2615 const _rich = document.getElementById('doc-email-richbody'); 2616 const _srcWrap = document.getElementById('doc-editor-wrap'); 2617 if (_rich && _srcWrap) { 2618 _srcWrap.style.display = 'none'; 2619 _rich.style.display = ''; 2620 _rich.innerHTML = _emailBodyToHtml(fields.body); 2621 _wireEmailRichbody(_rich); 2622 setTimeout(() => { 2623 try { 2624 const _isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0; 2625 if (!_isTouch) _rich.focus(); 2626 _rich.scrollTop = 0; 2627 } catch (_) {} 2628 }, 50); 2629 } 2630 // Render compose attachments (if any uploaded for this doc) 2631 _renderComposeAttachments();
2632 // Populate CC/BCC from parsed header, show rows if populated 2633 const ccRow = document.getElementById('doc-email-cc-row'); 2634 const bccRow = document.getElementById('doc-email-bcc-row'); 2635 const ccToggle = document.getElementById('doc-email-show-cc'); 2636 const ccInput = document.getElementById('doc-email-cc'); 2637 const bccInput = document.getElementById('doc-email-bcc'); 2638 if (ccInput) ccInput.value = fields.cc || ''; 2639 if (bccInput) bccInput.value = fields.bcc || ''; 2640 const hasCcBcc = !!(fields.cc || fields.bcc); 2641 if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none'; 2642 if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none'; 2643 if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : ''; 2644 _syncEmailHeaderSummary(); 2645 } 2646 2647 async function _uploadComposeFiles(files) { 2648 const list = Array.from(files || []); 2649 if (list.length === 0) return; 2650 const doc = docs.get(activeDocId); 2651 if (!doc) return; 2652 if (doc.language !== 'email') return; 2653 if (!doc._composeAtts) doc._composeAtts = []; 2654 2655 for (const file of list) { 2656 try { 2657 const fd = new FormData(); 2658 fd.append('file', file); 2659 const res = await fetch(`${API_BASE}/api/email/compose-upload`, { 2660 method: 'POST', 2661 body: fd, 2662 }); 2663 const data = await res.json(); 2664 if (data.success) { 2665 doc._composeAtts.push({ 2666 token: data.token, 2667 filename: data.filename, 2668 size: data.size, 2669 }); 2670 } else { 2671 if (uiModule) uiModule.showError(`Failed to upload ${file.name}: ${data.error || ''}`); 2672 } 2673 } catch (err) { 2674 if (uiModule) uiModule.showError(`Failed to upload ${file.name}`); 2675 } 2676 } 2677 _renderComposeAttachments(); 2678 } 2679 2680 async function _handleAttachUpload(e) { 2681 const files = e.target.files; 2682 e.target.value = ''; // reset for next upload 2683 await _uploadComposeFiles(files); 2684 } 2685 2686 function _renderComposeAttachments() { 2687 const container = document.getElementById('doc-email-compose-atts'); 2688 if (!container) return; 2689 const doc = docs.get(activeDocId); 2690 const atts = doc?._composeAtts || []; 2691 if (atts.length === 0) { 2692 container.style.display = 'none'; 2693 container.innerHTML = ''; 2694 return; 2695 } 2696 container.style.display = ''; 2697 container.innerHTML = ''; 2698 for (const att of atts) { 2699 const chip = document.createElement('span'); 2700 chip.className = 'email-compose-chip'; 2701 const sizeKb = att.size > 0 ? `${Math.round(att.size / 1024)} KB` : ''; 2702 chip.innerHTML = `
2703 <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> 2704 <span class="compose-chip-name">${_escHtml(att.filename)}</span> 2705 <span class="att-size">${sizeKb}</span> 2706 <button class="compose-chip-remove" title="Remove">Ã</button> 2707 `; 2708 chip.querySelector('.compose-chip-remove').addEventListener('click', async (e) => { 2709 e.stopPropagation(); 2710 try { 2711 await fetch(`${API_BASE}/api/email/compose-upload/${encodeURIComponent(att.token)}`, { method: 'DELETE' }); 2712 } catch (_) {} 2713 const d = docs.get(activeDocId); 2714 if (d) d._composeAtts = d._composeAtts.filter(a => a.token !== att.token); 2715 _renderComposeAttachments(); 2716 }); 2717 container.appendChild(chip); 2718 } 2719 } 2720 2721 // Split a To/Cc/Bcc text field into recipients + the in-progress fragment 2722 // the user is currently typing (after the last comma). Returns a tuple so 2723 // we can show suggestions for just the fragment without disturbing the 2724 // already-confirmed recipients. 2725 function _splitRecipientsAndFragment(rawValue) { 2726 const cut = (rawValue || '').lastIndexOf(','); 2727 if (cut < 0) return { confirmed: '', fragment: (rawValue || '').trimStart() }; 2728 return { 2729 confirmed: rawValue.slice(0, cut + 1).trimStart(), 2730 fragment: rawValue.slice(cut + 1).trimStart(), 2731 }; 2732 } 2733 2734 // Replace the in-progress fragment in `input` with the chosen email, 2735 // append ", " so the user can type the next recipient immediately, then 2736 // hide the suggestion dropdown. 2737 function _commitRecipient(input, sugg, email) { 2738 if (!input) return; 2739 const { confirmed } = _splitRecipientsAndFragment(input.value); 2740 // Preserve a single trailing space between commas for readability. 2741 const head = confirmed ? confirmed.replace(/\s+$/, '') + ' ' : ''; 2742 input.value = head + email + ', '; 2743 if (sugg) sugg.style.display = 'none'; 2744 input.focus(); 2745 // Caret to end so the next keystroke lands in the right place. 2746 const end = input.value.length; 2747 try { input.setSelectionRange(end, end); } catch (_) {} 2748 } 2749 2750 // Search contacts for an autocomplete dropdown. `input` is the To/Cc/Bcc 2751 // text field, `sugg` is its sibling .email-autocomplete div. Suggestions 2752 // are scoped to the LAST comma-separated fragment so already-entered 2753 // recipients aren't disturbed. 2754 async function _searchContacts(input, sugg) { 2755 if (!input || !sugg) return; 2756 const { fragment } = _splitRecipientsAndFragment(input.value); 2757 if (!fragment || fragment.length < 1) { sugg.style.display = 'none'; return; } 2758 try { 2759 const res = await fetch(`${API_BASE}/api/contacts/search?q=${encodeURIComponent(fragment)}`); 2760 const data = await res.json(); 2761 if (!data.results || data.results.length === 0) { 2762 sugg.style.display = 'none'; 2763 return; 2764 } 2765 // Already-entered emails in this field â skip in the dropdown so 2766 // users don't accidentally add the same person twice. 2767 const already = new Set( 2768 (input.value || '').split(',').map(s => { 2769 const m = s.match(/<([^>]+)>/); 2770 return (m ? m[1] : s).trim().toLowerCase(); 2771 }).filter(Boolean) 2772 ); 2773 sugg.innerHTML = ''; 2774 let count = 0; 2775 for (const c of data.results) { 2776 for (const em of (c.emails || [])) { 2777 if (already.has(em.toLowerCase())) continue; 2778 const item = document.createElement('div'); 2779 item.className = 'contact-suggestion'; 2780 item.innerHTML = `<span class="contact-name">${_escHtml(c.name)}</span><span class="contact-email">${_escHtml(em)}</span>`; 2781 // mousedown fires before blur so the click doesn't get lost 2782 item.addEventListener('mousedown', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); }); 2783 item.addEventListener('click', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); }); 2784 sugg.appendChild(item); 2785 count += 1; 2786 } 2787 } 2788 if (count === 0) { sugg.style.display = 'none'; return; } 2789 // Auto-highlight first suggestion so Enter accepts it. 2790 const first = sugg.querySelector('.contact-suggestion'); 2791 if (first) first.classList.add('active'); 2792 sugg.style.display = ''; 2793 } catch (e) { 2794 sugg.style.display = 'none'; 2795 } 2796 } 2797 2798 // Bind input/keydown/blur for a recipient field so it gets the same 2799 // autocomplete-and-commit behavior. Used by To/Cc/Bcc. 2800 function _wireRecipientAutocomplete(inputId, suggId) { 2801 const input = document.getElementById(inputId); 2802 const sugg = document.getElementById(suggId); 2803 if (!input || !sugg) return; 2804 let timer = null; 2805 input.addEventListener('input', () => { 2806 if (timer) clearTimeout(timer); 2807 timer = setTimeout(() => _searchContacts(input, sugg), 150); 2808 }); 2809 input.addEventListener('blur', () => { 2810 setTimeout(() => { sugg.style.display = 'none'; }, 200); 2811 }); 2812 input.addEventListener('keydown', (e) => { 2813 const open = sugg.style.display !== 'none'; 2814 const items = open ? sugg.querySelectorAll('.contact-suggestion') : []; 2815 const active = open ? sugg.querySelector('.contact-suggestion.active') : null; 2816 let idx = active ? Array.from(items).indexOf(active) : -1; 2817 if (open && e.key === 'ArrowDown') { 2818 e.preventDefault(); 2819 idx = Math.min(items.length - 1, idx + 1);
2820 items.forEach(it => it.classList.remove('active')); 2821 if (items[idx]) items[idx].classList.add('active'); 2822 } else if (open && e.key === 'ArrowUp') { 2823 e.preventDefault(); 2824 idx = Math.max(0, idx - 1); 2825 items.forEach(it => it.classList.remove('active')); 2826 if (items[idx]) items[idx].classList.add('active'); 2827 } else if (e.key === 'Enter') { 2828 // If a suggestion is highlighted, commit it. Otherwise â if the 2829 // current fragment already looks like a complete email â commit 2830 // the raw text so users who type a brand-new address don't have 2831 // to add the comma themselves. 2832 if (active) { 2833 e.preventDefault(); 2834 const em = active.querySelector('.contact-email')?.textContent?.trim(); 2835 if (em) _commitRecipient(input, sugg, em); 2836 } else { 2837 const { fragment } = _splitRecipientsAndFragment(input.value); 2838 if (/^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/.test(fragment.trim())) { 2839 e.preventDefault(); 2840 _commitRecipient(input, sugg, fragment.trim()); 2841 } 2842 } 2843 } else if (e.key === 'Tab' && active) { 2844 e.preventDefault(); 2845 const em = active.querySelector('.contact-email')?.textContent?.trim(); 2846 if (em) _commitRecipient(input, sugg, em); 2847 } else if (e.key === 'Escape') { 2848 sugg.style.display = 'none'; 2849 } else if (e.key === ',' || (e.key === ' ' && input.value.trim().endsWith(','))) { 2850 // Typing a comma directly also accepts a highlighted suggestion. 2851 if (active) { 2852 e.preventDefault(); 2853 const em = active.querySelector('.contact-email')?.textContent?.trim(); 2854 if (em) _commitRecipient(input, sugg, em); 2855 } 2856 } 2857 }); 2858 } 2859 2860 function _hideEmailFields() { 2861 const emailHeader = document.getElementById('doc-email-header'); 2862 const emailActions = document.getElementById('doc-email-actions'); 2863 if (emailHeader) emailHeader.style.display = 'none'; 2864 if (emailActions) emailActions.style.display = 'none'; 2865 // Restore toolbar items that were hidden for email (Code dropdown). 2866 document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; }); 2867 // Restore the generic documents action bar + its bottom footer (Close / 2868 // Copy / Export) for non-email docs. 2869 const docActions = document.getElementById('doc-editor-actions'); 2870 if (docActions) docActions.style.display = ''; 2871 const docFooter = document.getElementById('doc-actions-footer'); 2872 if (docFooter) docFooter.style.display = ''; 2873 // Return the type picker to its non-email home (right before the 2874 // Copy/Export split) â _showEmailFields moved it into the email footer. 2875 if (docFooter) { 2876 const _lang = document.getElementById('doc-language-select'); 2877 const _split = docFooter.querySelector('#doc-copy-export-split'); 2878 if (_lang && _split) docFooter.insertBefore(_lang, _split); 2879 } 2880 // Restore the source editor and hide the WYSIWYG email body. 2881 const _rich = document.getElementById('doc-email-richbody'); 2882 if (_rich) _rich.style.display = 'none'; 2883 const _srcWrap = document.getElementById('doc-editor-wrap'); 2884 if (_srcWrap) _srcWrap.style.display = ''; 2885 // Drop the email-mode class so editors return to monospace monochrome 2886 document.getElementById('doc-editor-textarea')?.classList.remove('email-mode'); 2887 document.getElementById('doc-editor-code')?.classList.remove('email-mode'); 2888 document.getElementById('doc-editor-highlight')?.classList.remove('email-mode'); 2889 } 2890 2891 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; 2892 2893 function _bodyMentionsAttachment(text) { 2894 if (!text) return false; 2895 // Only check the user's own text, not quoted replies 2896 const parts = text.split(/^>|^On .* wrote:/m); 2897 const own = parts[0] || ''; 2898 return _ATTACH_RE.test(own); 2899 } 2900 2901 function _confirmMissingAttachment() { 2902 return new Promise(resolve => {
2903 const overlay = document.createElement('div'); 2904 overlay.className = 'modal'; 2905 overlay.style.display = 'flex'; 2906 overlay.innerHTML = ` 2907 <div class="modal-content" style="width:360px;max-width:90vw;"> 2908 <div class="modal-header"><h4>No attachments found</h4></div> 2909 <div class="modal-body" style="padding:16px;font-size:13px;opacity:0.8;"> 2910 Your message mentions an attachment, but nothing is attached. Send anyway? 2911 </div> 2912 <div class="modal-footer" style="display:flex;gap:8px;justify-content:flex-end;"> 2913 <button class="memory-toolbar-btn" id="att-warn-cancel">Go back</button> 2914 <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> 2915 </div> 2916 </div> 2917 `; 2918 document.body.appendChild(overlay); 2919 const cleanup = (val) => { overlay.remove(); resolve(val); }; 2920 overlay.querySelector('#att-warn-cancel').addEventListener('click', () => cleanup(false)); 2921 overlay.querySelector('#att-warn-send').addEventListener('click', () => cleanup(true)); 2922 overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); 2923 }); 2924 } 2925 2926 async function _sendEmail() { 2927 const sendDocId = activeDocId; 2928 const to = document.getElementById('doc-email-to')?.value?.trim(); 2929 const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; 2930 const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; 2931 const subject = document.getElementById('doc-email-subject')?.value?.trim(); 2932 const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); 2933 const references = document.getElementById('doc-email-references')?.value?.trim(); 2934 const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim(); 2935 const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; 2936 // WYSIWYG: the rich body's HTML becomes the email's HTML part (server 2937 // sanitizes it). `body` (plain text mirror) stays the text/plain fallback. 2938 const _rich = _emailRichbodyActive(); 2939 if (_rich) _syncEmailRichbody(_rich); 2940 const textarea = document.getElementById('doc-editor-textarea'); 2941 const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim(); 2942 const bodyHtml = _rich ? _rich.innerHTML : null; 2943 const doc = docs.get(activeDocId); 2944 const attachments = (doc?._composeAtts || []).map(a => a.token); 2945 if (!to || !body) { 2946 if (uiModule) uiModule.showError('To and body are required'); 2947 return; 2948 } 2949 if (inReplyTo && !_emailReplyOwnText(body)) { 2950 if (uiModule) uiModule.showError('Reply body is empty'); 2951 return; 2952 } 2953 // Warn if body mentions attachments but none are actually attached 2954 if (attachments.length === 0 && _bodyMentionsAttachment(body)) { 2955 const proceed = await _confirmMissingAttachment(); 2956 if (!proceed) return; 2957 } 2958 const btn = document.getElementById('doc-email-send-btn'); 2959 const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 2960 let sendSpinner = null; 2961 let origBtnHtml = ''; 2962 let detachedEmailDoc = null; 2963 if (btn) { 2964 btn.disabled = true; 2965 origBtnHtml = btn.innerHTML; 2966 sendSpinner = spinnerModule.createWhirlpool(14); 2967 sendSpinner.element.style.cssText = 'display:inline-block;vertical-align:-2px;margin-right:6px;width:14px;height:14px;'; 2968 btn.innerHTML = ''; 2969 btn.appendChild(sendSpinner.element); 2970 btn.appendChild(document.createTextNode('Sending')); 2971 } 2972 try { 2973 let canceled = false; 2974 if (uiModule) { 2975 uiModule.showToast('Sending', { 2976 duration: 3200, 2977 leadingIcon: 'spinner', 2978 action: 'Cancel', 2979 onAction: () => { canceled = true; }, 2980 }); 2981 } 2982 await _sleep(3000); 2983 if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId); 2984 await _sleep(200); 2985 if (canceled) { 2986 _restoreDetachedEmailDoc(detachedEmailDoc); 2987 detachedEmailDoc = null; 2988 if (uiModule) uiModule.showToast('Send canceled'); 2989 return; 2990 } 2991 2992 const activeAccountId = await _resolveComposeSendAccountId(); 2993 const res = await fetch(`${API_BASE}/api/email/send`, { 2994 method: 'POST', 2995 credentials: 'same-origin', 2996 headers: { 'Content-Type': 'application/json' }, 2997 body: JSON.stringify({ 2998 to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml, 2999 in_reply_to: inReplyTo || null, references: references || null, 3000 attachments: attachments.length > 0 ? attachments : null, 3001 account_id: activeAccountId, 3002 wait_for_delivery: true, 3003 }), 3004 }); 3005 let data = null;
3006 try { 3007 data = await res.json(); 3008 } catch (_) { 3009 data = { success: false, error: `Send failed (${res.status})` }; 3010 } 3011 if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`; 3012 if (data.success) { 3013 if (uiModule) { 3014 uiModule.showToast('Message sent', { 3015 duration: 7000, 3016 leadingIcon: 'check', 3017 action: 'View Message', 3018 onAction: () => { 3019 import('./emailLibrary.js').then(mod => { 3020 const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); 3021 if (open) open({ 3022 account_id: data.account_id || activeAccountId || null, 3023 folder: data.sent_folder || 'Sent', 3024 uid: data.sent_uid || null, 3025 }); 3026 }).catch(() => {}); 3027 }, 3028 }); 3029 } 3030 // Auto-save recipients to the configured contacts backend (CardDAV). 3031 // The compose fields accept plain emails and "Name <email>" chips. 3032 const _contactPieces = [to, cc, bcc].join(',').split(/[,;]/).map(s => s.trim()).filter(Boolean); 3033 const _seenContacts = new Set(); 3034 for (const piece of _contactPieces) { 3035 const match = piece.match(/^(.*?)<([^>]+)>$/); 3036 const email = (match ? match[2] : piece).trim(); 3037 const name = (match ? match[1] : '').replace(/^["']|["']$/g, '').trim(); 3038 if (!email || !/@/.test(email)) continue; 3039 const key = email.toLowerCase(); 3040 if (_seenContacts.has(key)) continue; 3041 _seenContacts.add(key); 3042 fetch(`${API_BASE}/api/contacts/add`, { 3043 method: 'POST', 3044 credentials: 'same-origin', 3045 headers: { 'Content-Type': 'application/json' }, 3046 body: JSON.stringify({ name, email }), 3047 }).catch(() => {}); 3048 } 3049 // Mark the source email as answered if this was a reply 3050 if (sourceUid) { 3051 fetch(`${API_BASE}/api/email/mark-answered/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }).catch(() => {}); 3052 // Tell the inbox to refresh so the answered state shows 3053 window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid } })); 3054 } 3055 // Delete the compose document after successful send. It was usually 3056 // already detached from the visible tabs so sending can finish in the 3057 // background while the user continues in the next tab. 3058 if (sendDocId) { 3059 fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' }).catch(() => {}); 3060 const wasActiveSentDoc = activeDocId === sendDocId; 3061 docs.delete(sendDocId); 3062 if (wasActiveSentDoc) { 3063 activeDocId = null; 3064 const nextId = _visibleDocIdsForCurrentSession().find(id => docs.has(id)); 3065 if (nextId) switchToDoc(nextId); 3066 else closePanel(); 3067 } else { 3068 renderTabs(); 3069 } 3070 _syncDocIndicator(); 3071 } 3072 } else { 3073 _restoreDetachedEmailDoc(detachedEmailDoc); 3074 detachedEmailDoc = null; 3075 if (uiModule) uiModule.showError(data.error || 'Failed to send'); 3076 } 3077 } catch (e) { 3078 _restoreDetachedEmailDoc(detachedEmailDoc); 3079 detachedEmailDoc = null; 3080 if (uiModule) uiModule.showError(e?.message ? `Failed to send email: ${e.message}` : 'Failed to send email'); 3081 } finally { 3082 if (sendSpinner) sendSpinner.destroy(); 3083 if (btn) { 3084 btn.disabled = false; 3085 if (origBtnHtml) btn.innerHTML = origBtnHtml; 3086 } 3087 } 3088 } 3089 3090 async function _saveDraft() { 3091 const to = document.getElementById('doc-email-to')?.value?.trim(); 3092 const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; 3093 const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; 3094 const subject = document.getElementById('doc-email-subject')?.value?.trim(); 3095 const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); 3096 const references = document.getElementById('doc-email-references')?.value?.trim(); 3097 const _rich = _emailRichbodyActive(); 3098 if (_rich) _syncEmailRichbody(_rich); 3099 const textarea = document.getElementById('doc-editor-textarea'); 3100 const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim(); 3101 const bodyHtml = _rich ? _rich.innerHTML : null; 3102 const btn = document.getElementById('doc-email-draft-btn'); 3103 if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; } 3104 const controller = new AbortController(); 3105 const timeout = setTimeout(() => controller.abort(), 18000); 3106 try { 3107 const res = await fetch(`${API_BASE}/api/email/draft`, { 3108 method: 'POST', 3109 headers: { 'Content-Type': 'application/json' }, 3110 signal: controller.signal, 3111 body: JSON.stringify({ 3112 to: to || '', 3113 cc: cc || null, 3114 bcc: bcc || null, 3115 subject: subject || '', 3116 body: body || '', 3117 body_html: bodyHtml, 3118 in_reply_to: inReplyTo || null, 3119 references: references || null, 3120 account_id: window.__odysseusActiveEmailAccount || null, 3121 }), 3122 }); 3123 const data = await res.json(); 3124 if (data.success) { 3125 if (uiModule) uiModule.showToast('Draft saved to mailbox'); 3126 } else { 3127 if (uiModule) uiModule.showError(data.error || 'Failed to save draft'); 3128 } 3129 } catch (e) { 3130 const timedOut = e && e.name === 'AbortError'; 3131 if (uiModule) uiModule.showError(timedOut ? 'Saving draft timed out' : 'Failed to save draft'); 3132 } finally { 3133 clearTimeout(timeout); 3134 if (btn) { btn.disabled = false; btn.textContent = 'Draft'; } 3135 } 3136 } 3137 3138 function _discardEmail() { 3139 if (!activeDocId) return; 3140 // Just close â the Draft button handles saving explicitly 3141 _closeWithoutDeleting(true); 3142 } 3143 3144 function _visibleDocIdsForCurrentSession() { 3145 const curSession = sessionModule?.getCurrentSessionId() || ''; 3146 const ids = [];
3147 for (const [id, doc] of docs) { 3148 if (doc.sessionId && curSession && doc.sessionId !== curSession) continue; 3149 ids.push(id); 3150 } 3151 return ids; 3152 } 3153 3154 function _detachActiveEmailForBackground(docId) { 3155 if (!docId || !docs.has(docId)) return null; 3156 saveCurrentToMap(); 3157 const doc = docs.get(docId); 3158 const snapshot = { id: docId, doc: { ...doc } }; 3159 const wasActive = activeDocId === docId; 3160 if (wasActive) saveDocument({ silent: true }).catch(() => {}); 3161 3162 const visibleBefore = _visibleDocIdsForCurrentSession(); 3163 const idx = visibleBefore.indexOf(docId); 3164 docs.delete(docId); 3165 if (wasActive) activeDocId = null; 3166 3167 if (wasActive) { 3168 const remaining = visibleBefore.filter(id => id !== docId && docs.has(id)); 3169 const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null; 3170 if (nextId) { 3171 switchToDoc(nextId); 3172 } else { 3173 closePanel(); 3174 } 3175 } 3176 renderTabs(); 3177 _syncDocIndicator(); 3178 return snapshot; 3179 } 3180 3181 function _restoreDetachedEmailDoc(snapshot) { 3182 if (!snapshot || !snapshot.id || !snapshot.doc) return; 3183 if (!docs.has(snapshot.id)) docs.set(snapshot.id, snapshot.doc); 3184 _ensureDocPaneMounted(); 3185 switchToDoc(snapshot.id); 3186 _syncDocIndicator(); 3187 } 3188 3189 function _closeWithoutDeleting(deleteDoc = false) { 3190 if (!activeDocId) return; 3191 if (deleteDoc) { 3192 fetch(`${API_BASE}/api/document/${activeDocId}`, { method: 'DELETE' }).catch(() => {}); 3193 } 3194 // Save the current state to the doc first so it persists in the library 3195 saveCurrentToMap(); 3196 if (!deleteDoc) { 3197 saveDocument({ silent: true }).catch(() => {}); 3198 } 3199 docs.delete(activeDocId); 3200 const remaining = Array.from(docs.keys()); 3201 if (remaining.length > 0) { 3202 switchToDoc(remaining[0]); 3203 } else { 3204 closePanel(); 3205 } 3206 renderTabs(); 3207 } 3208 3209 async function _aiReply() { 3210 const to = document.getElementById('doc-email-to')?.value?.trim() || ''; 3211 const subject = document.getElementById('doc-email-subject')?.value?.trim() || ''; 3212 const textarea = document.getElementById('doc-editor-textarea'); 3213 if (!textarea) return; 3214 const currentBody = textarea.value || ''; 3215 const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || ''; 3216 const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || ''; 3217 const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; 3218 const cleanAiReplyText = (text) => { 3219 if (!text) return ''; 3220 let t = String(text); 3221 const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i; 3222 const close = /<<<\s*END\s*>>+/i; 3223 const m = open.exec(t); 3224 if (m) { 3225 const rest = t.slice(m.index + m[0].length); 3226 const c = close.exec(rest); 3227 t = c ? rest.slice(0, c.index) : rest; 3228 } 3229 return t 3230 .replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '') 3231 .replace(/<<<\s*END\s*>>+/gi, '') 3232 .trim(); 3233 }; 3234 const shouldUseFastAiReply = () => { 3235 const text = `${subject}\n${currentBody}`.toLowerCase(); 3236 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)) { 3237 return false; 3238 } 3239 return currentBody.length < 2500; 3240 }; 3241 3242 // Use the current chat model 3243 let currentModel = ''; 3244 let currentSessionId = ''; 3245 try { 3246 currentModel = sessionModule?.getCurrentModel() || ''; 3247 currentSessionId = sessionModule?.getCurrentSessionId() || ''; 3248 } catch (_) {} 3249 3250 const btn = document.getElementById('doc-email-ai-reply-btn'); 3251 if (btn) { btn.disabled = true;
3251 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...'; } 3252 3253 try { 3254 const res = await fetch(`${API_BASE}/api/email/ai-reply`, { 3255 method: 'POST', 3256 headers: { 'Content-Type': 'application/json' }, 3257 body: JSON.stringify({ 3258 to: to, 3259 subject: subject, 3260 original_body: currentBody, 3261 model: currentModel, 3262 session_id: currentSessionId, 3263 message_id: inReplyTo, 3264 uid: sourceUid, 3265 folder: sourceFolder, 3266 fast: shouldUseFastAiReply(), 3267 }), 3268 }); 3269 const data = await res.json(); 3270 if (data.success && data.reply) { 3271 const cleanReply = cleanAiReplyText(data.reply); 3272 const lines = currentBody.split('\n'); 3273 const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:')); 3274 let newBody = ''; 3275 if (quoteIdx > 0) { 3276 newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n'); 3277 } else { 3278 newBody = cleanReply + (currentBody ? '\n\n' + currentBody : ''); 3279 } 3280 await _streamEmailBodyText(textarea, newBody); 3281 if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`); 3282 } else { 3283 if (uiModule) uiModule.showError(data.error || 'Failed to generate reply'); 3284 } 3285 } catch (e) { 3286 if (uiModule) uiModule.showError('Failed to generate AI reply'); 3287 } finally { 3288 if (btn) { btn.disabled = false;
3288 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'; } 3289 } 3290 } 3291 3292 async function _scheduleSend(anchorEl = null) { 3293 const to = document.getElementById('doc-email-to')?.value?.trim(); 3294 const cc = document.getElementById('doc-email-cc')?.value?.trim() || ''; 3295 const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || ''; 3296 const subject = document.getElementById('doc-email-subject')?.value?.trim(); 3297 const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); 3298 const references = document.getElementById('doc-email-references')?.value?.trim(); 3299 const _rich = _emailRichbodyActive(); 3300 if (_rich) _syncEmailRichbody(_rich); 3301 const body = (_rich 3302 ? (_rich.innerText || _rich.textContent || '') 3303 : (document.getElementById('doc-editor-textarea')?.value || '') 3304 ).trim(); 3305 const doc = docs.get(activeDocId); 3306 const attachments = (doc?._composeAtts || []).map(a => a.token); 3307 3308 if (!to || !body) { 3309 if (uiModule) uiModule.showError('To and body are required'); 3310 return; 3311 } 3312 if (inReplyTo && !_emailReplyOwnText(body)) { 3313 if (uiModule) uiModule.showError('Reply body is empty'); 3314 return; 3315 } 3316 if (attachments.length === 0 && _bodyMentionsAttachment(body)) { 3317 const proceed = await _confirmMissingAttachment(); 3318 if (!proceed) return; 3319 } 3320 3321 // Create a small modal with datetime input and quick presets 3322 const overlay = document.createElement('div'); 3323 overlay.className = 'modal'; 3324 overlay.style.display = 'flex'; 3325 overlay.innerHTML = ` 3326 <div class="modal-content schedule-send-modal" style="width:400px;max-width:92vw;"> 3327 <div class="modal-header"> 3328 <h4>Schedule Send</h4> 3329 <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> 3330 </div> 3331 <div class="modal-body schedule-send-body"> 3332 <label class="schedule-send-label">Quick presets</label> 3333 <div class="schedule-send-presets"> 3334 <button class="memory-toolbar-btn" data-preset="1h">In 1 hour</button> 3335 <button class="memory-toolbar-btn" data-preset="3h">In 3 hours</button> 3336 <button class="memory-toolbar-btn" data-preset="tomorrow">Tomorrow 9am</button> 3337 <button class="memory-toolbar-btn" data-preset="monday">Monday 9am</button> 3338 </div> 3339 <label class="schedule-send-label" for="sched-datetime">Or pick a specific time</label> 3340 <input type="datetime-local" id="sched-datetime" class="schedule-send-datetime" /> 3341 </div> 3342 <div class="modal-footer schedule-send-footer"> 3343 <button class="memory-toolbar-btn" id="sched-cancel">Cancel</button> 3344 <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> 3345 </div> 3346 </div> 3347 `; 3348 document.body.appendChild(overlay); 3349 const modalContent = overlay.querySelector('.schedule-send-modal'); 3350 const anchor = anchorEl || document.getElementById('doc-email-send-caret') || document.getElementById('doc-email-send-btn'); 3351 if (modalContent && anchor) { 3352 const rect = anchor.getBoundingClientRect(); 3353 const gap = 8; 3354 const width = Math.min(400, Math.max(280, window.innerWidth - 16)); 3355 modalContent.style.width = `${width}px`; 3356 modalContent.style.position = 'fixed'; 3357 modalContent.style.margin = '0'; 3358 modalContent.style.transform = 'none'; 3359 const left = Math.max(8, Math.min(window.innerWidth - width - 8, rect.right - width)); 3360 const belowTop = rect.bottom + gap; 3361 const estimatedHeight = Math.min(320, window.innerHeight - 16); 3362 const top = belowTop + estimatedHeight <= window.innerHeight - 8 3363 ? belowTop 3364 : Math.max(8, rect.top - estimatedHeight - gap); 3365 modalContent.style.left = `${left}px`; 3366 modalContent.style.top = `${top}px`; 3367 } 3368 3369 const dtInput = overlay.querySelector('#sched-datetime'); 3370 // Default to 1 hour from now 3371 const now = new Date(Date.now() + 60 * 60 * 1000); 3372 const pad = (n) => String(n).padStart(2, '0'); 3373 dtInput.value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; 3374 3375 const escHandler = (e) => { if (e.key === 'Escape') cleanup(); }; 3376 const cleanup = () => { 3377 overlay.remove(); 3378 document.removeEventListener('keydown', escHandler); 3379 }; 3380 overlay.querySelector('#sched-close').addEventListener('click', cleanup); 3381 overlay.querySelector('#sched-cancel').addEventListener('click', cleanup); 3382 overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); }); 3383 document.addEventListener('keydown', escHandler); 3384 3385 overlay.querySelectorAll('[data-preset]').forEach(btn => { 3386 btn.addEventListener('click', () => { 3387 const preset = btn.getAttribute('data-preset'); 3388 const d = new Date(); 3389 if (preset === '1h') d.setHours(d.getHours() + 1); 3390 else if (preset === '3h') d.setHours(d.getHours() + 3); 3391 else if (preset === 'tomorrow') { d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0); } 3392 else if (preset === 'monday') { 3393 const daysUntilMon = (8 - d.getDay()) % 7 || 7; 3394 d.setDate(d.getDate() + daysUntilMon); 3395 d.setHours(9, 0, 0, 0); 3396 } 3397 dtInput.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; 3398 }); 3399 }); 3400 3401 overlay.querySelector('#sched-confirm').addEventListener('click', async () => { 3402 const localDt = dtInput.value; 3403 if (!localDt) { if (uiModule) uiModule.showError('Please pick a time'); return; } 3404 // Convert local datetime to UTC ISO 3405 const utcIso = new Date(localDt).toISOString();
3406 try { 3407 const activeAccountId = await _resolveComposeSendAccountId(); 3408 const res = await fetch(`${API_BASE}/api/email/schedule`, { 3409 method: 'POST', 3410 headers: { 'Content-Type': 'application/json' }, 3411 body: JSON.stringify({ 3412 to, cc: cc || null, bcc: bcc || null, subject, body, 3413 in_reply_to: inReplyTo || null, 3414 references: references || null, 3415 attachments: attachments.length > 0 ? attachments : null, 3416 send_at: utcIso, 3417 account_id: activeAccountId, 3418 }), 3419 }); 3420 const data = await res.json(); 3421 if (data.success) { 3422 if (uiModule) uiModule.showToast(`Scheduled for ${new Date(localDt).toLocaleString()}`); 3423 cleanup(); 3424 // Close the document 3425 _closeWithoutDeleting(true); 3426 } else { 3427 if (uiModule) uiModule.showError(data.error || 'Failed to schedule'); 3428 } 3429 } catch (e) { 3430 if (uiModule) uiModule.showError('Failed to schedule'); 3431 } 3432 }); 3433 } 3434 3435 async function _markUnreadAndClose() { 3436 const sourceUid = document.getElementById('doc-email-source-uid')?.value || ''; 3437 const sourceFolder = document.getElementById('doc-email-source-folder')?.value || 'INBOX'; 3438 if (sourceUid) { 3439 try { 3440 await fetch(`${API_BASE}/api/email/mark-unread/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }); 3441 } catch (e) { console.error('Failed to mark unread:', e); } 3442 } 3443 _discardEmail(); 3444 } 3445 3446 function switchToDoc(docId) { 3447 if (!docs.has(docId)) return; 3448 _hideLoadingOverlay(); 3449 if (_diffModeActive) exitDiffMode(true); 3450 3451 // Save current doc state before switching 3452 saveCurrentToMap(); 3453 3454 // Auto-delete the doc we're leaving if it's completely empty 3455 const prevId = activeDocId; 3456 if (prevId && prevId !== docId && docs.has(prevId)) { 3457 const prev = docs.get(prevId); 3458 if (!(prev.content || '').trim() && !(prev.title || '').trim()) { 3459 fetch(`${API_BASE}/api/document/${prevId}`, { method: 'DELETE' }).catch(() => {}); 3460 docs.delete(prevId); 3461 _syncDocIndicator(); 3462 } 3463 } 3464 3465 activeDocId = docId; 3466 clearSelection(); 3467 const doc = docs.get(docId); 3468 3469 // Populate editor 3470 const titleInput = document.getElementById('doc-title-input'); 3471 const textarea = document.getElementById('doc-editor-textarea'); 3472 const langSelect = document.getElementById('doc-language-select'); 3473 const badge = document.getElementById('doc-version-badge'); 3474 3475 if (titleInput) titleInput.value = doc.title || ''; 3476 // For email docs, _showEmailFields will set textarea to body only (not raw header) 3477 if (textarea && doc.language !== 'email') textarea.value = doc.content || ''; 3478 if (langSelect) langSelect.value = doc.language || 'markdown'; 3479 if (badge) { const _v = doc.version || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; } 3480 { const _v = doc.version || 1; const _dbtn = document.getElementById('doc-diff-toggle-btn'); if (_dbtn) _dbtn.style.display = _v > 1 ? '' : 'none'; } 3481 syncHighlighting(); 3482 // Deferred re-sync: ensure minHeight is correct after browser layout 3483 requestAnimationFrame(() => { 3484 const ta2 = document.getElementById('doc-editor-textarea'); 3485 const code2 = document.getElementById('doc-editor-code'); 3486 const pre2 = document.getElementById('doc-editor-highlight'); 3487 if (ta2 && code2 && pre2) { 3488 code2.style.minHeight = ta2.scrollHeight + 'px'; 3489 pre2.scrollTop = ta2.scrollTop; 3490 } 3491 }); 3492 3493 // Auto-detect language for docs with no language set 3494 if (!doc.userSetLanguage && !doc.language) { 3495 setTimeout(attemptAutoDetect, 100); 3496 } 3497 3498 // Show/hide markdown toolbar based on language. PDF-backed docs are 3499 // markdown under the hood, so the toolbar shows up for them too â and 3500 // gets the PDF-specific buttons (Text/Check/Sign/AI) revealed below. 3501 const isMd = (doc.language || 'markdown') === 'markdown'; 3502 const isPdf = _isFormBackedDoc(doc.content || ''); 3503 3504 // For PDF-backed docs, re-run text extraction on the backend so the AI 3505 // can see the contents on the very next message. Idempotent + skipped 3506 // once per session per doc to avoid hammering the VL model on every 3507 // switch â track via a sentinel on the doc object. 3508 if (isPdf && !doc._ocrTriggered) { 3509 doc._ocrTriggered = true; 3510 (async () => { 3511 try { 3512 const r = await fetch(`${API_BASE}/api/document/${docId}/extract-pdf-text`, { method: 'POST', credentials: 'same-origin' }); 3513 if (!r.ok) return; 3514 const j = await r.json().catch(() => ({})); 3515 if (j && j.extracted) { 3516 // Pull the fresh content into the local cache so subsequent AI 3517 // turns and the source view both reflect the extraction. 3518 const dr = await fetch(`${API_BASE}/api/document/${docId}`, { credentials: 'same-origin' }); 3519 if (dr.ok) { 3520 const full = await dr.json(); 3521 const cached = docs.get(docId); 3522 if (cached && full && full.current_content) { 3523 cached.content = full.current_content; 3524 } 3525 } 3526 } 3527 } catch (_) {} 3528 })(); 3529 } 3530 const mdToolbar = document.getElementById('doc-md-toolbar'); 3531 if (mdToolbar) { 3532 // Show for every doc type so users always have access to font-size / 3533 // diff toggle / language-specific controls. Items inside the toolbar 3534 // gate their own visibility on language (md edit/preview toggle, etc). 3535 mdToolbar.style.display = ''; 3536 if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); 3537 } 3538 // Toggle PDF-only toolbar group 3539 document.querySelectorAll('.md-toolbar-pdf-only').forEach(el => { 3540 el.style.display = isPdf ? '' : 'none'; 3541 }); 3542 // Font size does nothing for a PDF (annotations are placed, not styled) â 3543 // hide it on PDFs so the toolbar only shows what actually works. 3544 const _fsBtn = document.getElementById('doc-fontsize-btn'); 3545 if (_fsBtn) _fsBtn.style.display = isPdf ? 'none' : ''; 3546 // Exit CSV preview when switching docs, or auto-show for CSV 3547 const isCsv = doc.language === 'csv'; 3548 const csvPreview = document.getElementById('doc-csv-preview'); 3549 if (!isCsv) { 3550 if (csvPreview) csvPreview.style.display = 'none'; 3551 } else { 3552 // Auto-show table view for CSV documents 3553 requestAnimationFrame(() => toggleCsvPreview()); 3554 } 3555 3556 // Exit HTML preview on switch 3557 exitHtmlPreview(); 3558 3559 // Show/hide email fields. Markdown preview uses the same editor wrapper 3560 // as email source mode, so clear it before showing the rich email body; 3561 // otherwise the source wrapper can reappear over the composer. 3562 const isEmail = doc.language === 'email'; 3563 if (isEmail) { 3564 _setMarkdownPreviewActive(false, { remember: false }); 3565 _showEmailFields(doc); 3566 } else { 3567 _hideEmailFields(); 3568 const wantsMarkdownPreview = (doc.language || 'markdown') === 'markdown' && doc._markdownPreviewActive === true; 3569 _setMarkdownPreviewActive(wantsMarkdownPreview, { remember: false }); 3570 } 3571 3572 // Hide version panel on switch 3573 const vp = document.getElementById('doc-version-panel'); 3574 if (vp) vp.classList.add('hidden'); 3575 3576 renderTabs(); 3577 _syncHeaderActions(); 3578 3579 // Restore any persisted suggestions for this doc 3580 if (_activeSuggestions.length === 0) { 3581 _restoreSuggestionsFromStorage(docId); 3582 } 3583 3584 } 3585 3586 // Detach a doc from its chat session so it stops reappearing in that 3587 // chat: docs with content are unlinked (kept in the library), empty docs 3588 // are deleted. Used by both the tab à and the mobile chip-to-trash close. 3589 function _detachDocFromSession(docId, { toast = false } = {}) { 3590 const doc = docs.get(docId); 3591 const hasContent = doc && doc.content && doc.content.trim().length > 0; 3592 if (hasContent) { 3593 fetch(`${API_BASE}/api/document/${docId}`, { 3594 method: 'PATCH', 3595 headers: { 'Content-Type': 'application/json' }, 3596 body: JSON.stringify({ session_id: '' }), 3597 }).then(() => {
3598 if (toast && uiModule) uiModule.showToast('Document unlinked from session'); 3599 }).catch(() => {}); 3600 } else { 3601 fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE' }).catch(() => {}); 3602 } 3603 docs.delete(docId); 3604 _syncDocIndicator(); 3605 } 3606 3607 async function closeTab(docId) { 3608 // Save current editor content to map so the check below uses fresh data 3609 saveCurrentToMap(); 3610 _detachDocFromSession(docId, { toast: true }); 3611 // Find next tab in the current session 3612 const curSession = sessionModule?.getCurrentSessionId() || ''; 3613 let nextId = null; 3614 for (const [id, d] of docs) { 3615 if (!d.sessionId || !curSession || d.sessionId === curSession) { 3616 nextId = id; 3617 break; 3618 } 3619 } 3620 if (!nextId) { 3621 activeDocId = null; 3622 closePanel(); 3623 return; 3624 } 3625 if (activeDocId === docId) { 3626 switchToDoc(nextId); 3627 } else { 3628 renderTabs(); 3629 } 3630 } 3631 3632 /** Auto-create a document when user types/pastes into empty editor */ 3633 let _autoCreating = false; 3634 // True while createDocument's POST is in flight â suppresses the type-to- 3635 // auto-create path so clicking "New document" and immediately typing can't 3636 // spawn a SECOND untitled doc (the create round-trip hadn't set activeDocId 3637 // yet, so the input handler thought the editor was empty). 3638 let _creatingDoc = false; 3639 async function _autoCreateFromInput(content) { 3640 if (_autoCreating) return; 3641 _autoCreating = true; 3642 try { 3643 let sessionId = _lastSessionId 3644 || (sessionModule && sessionModule.getCurrentSessionId()); 3645 if (!sessionId) { 3646 sessionId = await _autoCreateSession(); 3647 } 3648 const res = await fetch(`${API_BASE}/api/document`, { 3649 method: 'POST', 3650 headers: { 'Content-Type': 'application/json' }, 3651 body: JSON.stringify({ session_id: sessionId, title: '', content }), 3652 }); 3653 const doc = await res.json(); 3654 addDocToTabs(doc, sessionId); 3655 // Set the content into the map so switchToDoc preserves it 3656 const d = docs.get(doc.id); 3657 if (d) d.content = content; 3658 activeDocId = doc.id; 3659 // Update textarea (keep existing content the user typed) 3660 const textarea = document.getElementById('doc-editor-textarea'); 3661 if (textarea) { 3662 textarea.placeholder = 'Document content...'; 3663 } 3664 syncHighlighting(); 3665 renderTabs(); 3666 // Trigger auto-detect and auto-title 3667 setTimeout(attemptAutoDetect, 100); 3668 setTimeout(() => autoTitleFromContent(content), 300); 3669 // Auto-save 3670 clearTimeout(_autoSaveDebounce); 3671 _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000); 3672 } catch (e) { 3673 console.error('Failed to auto-create document from input:', e); 3674 } finally { 3675 _autoCreating = false; 3676 } 3677 } 3678 3679 /** Save current editor state back into the docs map */ 3680 function saveCurrentToMap() { 3681 if (!activeDocId || !docs.has(activeDocId)) return; 3682 const doc = docs.get(activeDocId); 3683 const textarea = document.getElementById('doc-editor-textarea'); 3684 const titleInput = document.getElementById('doc-title-input'); 3685 const langSelect = document.getElementById('doc-language-select'); 3686 if (titleInput) doc.title = titleInput.value; 3687 if (langSelect) doc.language = langSelect.value; 3688 // For email docs, reconstruct full content with header 3689 if (doc.language === 'email' && textarea) { 3690 const to = document.getElementById('doc-email-to')?.value || ''; 3691 const cc = document.getElementById('doc-email-cc')?.value || ''; 3692 const bcc = document.getElementById('doc-email-bcc')?.value || ''; 3693 const subject = document.getElementById('doc-email-subject')?.value || ''; 3694 const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value || ''; 3695 const references = document.getElementById('doc-email-references')?.value || ''; 3696 const sourceUid = document.getElementById('doc-email-source-uid')?.value || ''; 3697 const sourceFolder = document.getElementById('doc-email-source-folder')?.value || ''; 3698 // Persist the WYSIWYG body as HTML so reopening the draft keeps its 3699 // formatting (the textarea mirror is plain text). _emailBodyToHtml detects 3700 // the leading '<' on reload and restores it verbatim. 3701 const _rich = document.getElementById('doc-email-richbody'); 3702 const _emailBody = (_rich && _rich.style.display !== 'none') ? _rich.innerHTML : textarea.value; 3703 doc.content = _buildEmailContent(to, subject, inReplyTo, references, _emailBody, sourceUid, sourceFolder, cc, bcc); 3704 } else if (textarea) { 3705 // Don't clobber a PDF/form-backed doc's source when the textarea is empty 3706 // (it's hidden behind the rendered PDF view, so its value isn't the source 3707 // of truth). Overwriting here dropped the pdf_form_source marker, so after 3708 // minimizeârestore the doc came back blank. 3709 if (!(textarea.value === '' && _isFormBackedDoc(doc.content))) { 3710 doc.content = textarea.value; 3711 } 3712 } 3713 } 3714 3715 // ---- Panel open/close ---- 3716
3717 export function openPanel() { 3718 if (isOpen) return; 3719 // Clear any pane/divider still sliding out from a just-fired close so we 3720 // don't end up with two #doc-editor-pane nodes (and a stale close stripping 3721 // doc-view). Paired with the isOpen guard in _finishClose above. 3722 document.getElementById('doc-editor-pane')?.remove(); 3723 document.getElementById('doc-divider')?.remove(); 3724 // If the doc was minimized as a chip and the user opened the panel via 3725 // a different path (toolbar button, indicator), clear that chip â the 3726 // doc is becoming visible again. 3727 if (Modals.isRegistered('doc-panel') && Modals.isMinimized('doc-panel')) { 3728 _minimizedDocId = null; 3729 Modals.unregister('doc-panel'); 3730 } 3731 const container = document.getElementById('chat-container'); 3732 if (!container) return; 3733 3734 isOpen = true; 3735 // Doc was opened last â it goes in front of the email windows (clears the 3736 // email-front flag; the doc/email z-index alternation lives in CSS). 3737 document.body.classList.remove('email-front'); 3738 _ensureAgentMode(); 3739 _markDocVisibleState(_lastSessionId, 'open'); 3740 3741 document.body.classList.add('doc-view'); 3742 3743 // Sync toggle button state 3744 const toggleBtn = document.getElementById('overflow-doc-btn'); 3745 if (toggleBtn) toggleBtn.classList.add('active'); 3746 const docInd = document.getElementById('doc-indicator-btn'); 3747 if (docInd) docInd.classList.add('active'); 3748 3749 // Create divider â grip in the middle (drag-to-resize), swapped for a 3750 // clickable collapse chevron on hover. 3751 const divider = document.createElement('div'); 3752 divider.className = 'doc-divider'; 3753 divider.id = 'doc-divider'; 3754 // Single chevron that swaps direction based on cursor position: 3755 // - cursor INSIDE the doc pane â ⺠(collapse / close panel) 3756 // - cursor OUTSIDE the doc pane â â¹ (fullscreen â grow leftward) 3757 // The arrow rotates via CSS so the swap feels clean. The action follows 3758 // the glyph, so clicking always does what the arrow promises. 3759 // The secondary X button below it is only shown in fullscreen mode and 3760 // hides the pane outright (so fullscreen has an escape that isn't just 3761 // "exit fullscreen"). 3762 divider.innerHTML = '<button type="button" class="doc-divider-collapse" title="Collapse panel" data-mode="collapse"><span>âº</span></button>' + 3763 '<button type="button" class="doc-divider-hide" title="Hide panel" aria-label="Hide panel">
3763<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>'; 3764 const _divHide = divider.querySelector('.doc-divider-hide'); 3765 if (_divHide) { 3766 _divHide.addEventListener('mousedown', (e) => e.stopPropagation()); 3767 _divHide.addEventListener('click', (e) => { e.stopPropagation(); closePanel('down'); }); 3768 } 3769 3770 // Create the editor pane 3771 const pane = document.createElement('div'); 3772 pane.id = 'doc-editor-pane'; 3773 pane.className = 'doc-editor-pane'; 3774 // ââ Mobile: make toolbar/footer buttons work on the FIRST tap with the 3775 // keyboard up ââ 3776 // Normally a tap while the keyboard is open is eaten by the OS keyboard 3777 // dismissal and the button's click never fires ("nothing triggers"). Keep 3778 // the field focused through the press so the tap isn't consumed, then 3779 // re-dispatch the click on release so the action fires on the first tap. 3780 // The action handler itself decides whether to then drop the keyboard 3781 // (Undo/Export/Close do; Format/Copy keep it). Touch only â desktop is 3782 // untouched. 3783 { 3784 let _kbBtn = null; 3785 pane.addEventListener('pointerdown', (e) => { 3786 _kbBtn = null; 3787 if (e.pointerType !== 'touch') return; 3788 const btn = e.target.closest && e.target.closest('button'); 3789 if (!btn) return; 3790 const ae = document.activeElement; 3791 if (!(ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA'))) return; 3792 e.preventDefault(); // keep focus; this cancels the native touch click 3793 _kbBtn = btn; 3794 }, true); 3795 pane.addEventListener('pointerup', (e) => { 3796 const btn = _kbBtn; _kbBtn = null; 3797 if (!btn) return; 3798 if (e.target.closest && e.target.closest('button') === btn) { 3799 btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); 3800 } 3801 }, true); 3802 pane.addEventListener('pointercancel', () => { _kbBtn = null; }, true); 3803 } 3804 pane.innerHTML = ` 3805 <input type="hidden" id="doc-title-input" value="" /> 3806 <div class="doc-mobile-grabber" id="doc-mobile-grabber" aria-hidden="true"></div> 3807 <div class="doc-editor-header" id="doc-editor-actions"> 3808 <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> 3809 <button id="doc-header-preview-btn" class="doc-action-icon-btn" title="Run / Preview" style="display:none;opacity:0.85;gap:4px;"></button> 3810 <span id="doc-stream-indicator" class="doc-stream-indicator" style="display:none"><span class="doc-stream-dot"></span> editing</span> 3811 <span id="doc-version-badge" class="doc-version-badge" title="Version history" style="display:none">v1</span> 3812 <span style="flex:1"></span> 3813 <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> 3814 <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> 3815 <select id="doc-language-select" class="doc-language-select"> 3816 <option value="">type</option> 3817 <option value="python">python</option> 3818 <option value="javascript">javascript</option> 3819 <option value="typescript">typescript</option> 3820 <option value="html">html</option> 3821 <option value="css">css</option> 3822 <option value="markdown">markdown</option> 3823 <option value="json">json</option> 3824 <option value="yaml">yaml</option> 3825 <option value="bash">bash</option> 3826 <option value="sql">sql</option> 3827 <option value="rust">rust</option> 3828 <option value="go">go</option> 3829 <option value="java">java</option> 3830 <option value="c">c</option> 3831 <option value="cpp">c++</option> 3832 <option value="csharp">
3832c#</option> 3833 <option value="xml">xml</option> 3834 <option value="svg">svg</option> 3835 <option value="toml">toml</option> 3836 <option value="ini">ini</option> 3837 <option value="ruby">ruby</option> 3838 <option value="php">php</option> 3839 <option value="csv">csv</option> 3840 <option value="email">email</option> 3841 <option value="pdf">pdf</option> 3842 </select> 3843 <!-- Close + Copy/Export moved to the bottom action footer (#doc-actions-footer) 3844 so regular docs match the email footer layout. --> 3845 </div> 3846 <div class="doc-tab-bar" id="doc-tab-bar"></div> 3847 <div id="doc-email-header" class="doc-email-header" style="display:none"> 3848 <button type="button" id="doc-email-collapse-btn" class="doc-email-collapse-btn" title="Hide email fields" aria-expanded="true"> 3849 <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> 3850 <span id="doc-email-collapse-summary" class="doc-email-collapse-summary">No recipient · No subject</span> 3851 </button> 3852 <div id="doc-email-fields" class="doc-email-fields"> 3853 <div class="email-field" style="position:relative"> 3854 <label>To</label> 3855 <input type="text" id="doc-email-to" placeholder="[email protected]" autocomplete="off" /> 3856 <div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div> 3857 <button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button> 3858 </div> 3859 <div class="email-field" id="doc-email-cc-row" style="display:none;position:relative"> 3860 <label>Cc</label> 3861 <input type="text" id="doc-email-cc" placeholder="[email protected]" autocomplete="off" /> 3862 <div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div> 3863 </div> 3864 <div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative"> 3865 <label>Bcc</label> 3866 <input type="text" id="doc-email-bcc" placeholder="[email protected]" autocomplete="off" /> 3867 <div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div> 3868 </div> 3869 <div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div> 3870 <div id="doc-email-attachments" class="email-attachments" style="display:none"></div> 3871 <div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div> 3872 </div> 3873 <input type="hidden" id="doc-email-in-reply-to" /> 3874 <input type="hidden" id="doc-email-references" /> 3875 <input type="hidden" id="doc-email-source-uid" /> 3876 <input type="hidden" id="doc-email-source-folder" /> 3877 <input type="file" id="doc-email-file-input" multiple style="display:none" /> 3878 </div> 3879 <div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none"> 3880 <div class="md-toolbar-items" id="md-toolbar-items"> 3881 <span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview"> 3882 <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> 3883 <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> 3884 </span> 3885 <span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run"> 3886 <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> 3887 <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> 3888 </span> 3889 <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> 3890 <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>
3891 <span class="md-toolbar-sep"></span> 3892 <button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button> 3893 <button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button> 3894 <button type="button" data-md="strike" title="Strikethrough"><s>S</s></button> 3895 <span class="md-toolbar-sep"></span> 3896 <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> 3897 <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> 3898 <span class="md-toolbar-sep"></span> 3899 <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> 3900 <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> 3901 <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> 3902 <button type="button" data-md="hr" title="Horizontal rule">â</button> 3903 <span class="md-toolbar-sep"></span> 3904 <span id="md-toolbar-emoji-slot"></span> 3905 <span class="md-toolbar-sep md-toolbar-pdf-only" style="display:none"></span> 3906 <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> 3907 <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> 3908 <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> 3909 <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> 3910 </div> 3911 <div class="md-toolbar-overflow-wrapper" id="md-toolbar-overflow-wrapper" style="display:none"> 3912 <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> 3913 <div class="md-toolbar-overflow-menu" id="md-toolbar-overflow-menu"></div> 3914 </div> 3915 <button type="button" class="md-scroll-arrow md-scroll-left" id="md-scroll-left" title="Scroll left" style="display:none">
3915<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> 3916 <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> 3917 </div> 3918 <div id="doc-find-bar" class="doc-find-bar" style="display:none"> 3919 <input id="doc-find-input" class="doc-find-input" type="text" placeholder="Find..." /> 3920 <span id="doc-find-count" class="doc-find-count"></span> 3921 <button id="doc-find-prev" class="doc-find-nav" title="Previous">↑</button> 3922 <button id="doc-find-next" class="doc-find-nav" title="Next">↓</button> 3923 <button id="doc-find-close" class="doc-find-close" title="Close">×</button> 3924 </div> 3925 <div id="doc-editor-wrap" class="doc-editor-wrap"> 3926 <div id="doc-line-numbers" class="doc-line-numbers">1</div> 3927 <pre id="doc-editor-highlight" class="doc-editor-highlight"><code id="doc-editor-code"></code></pre> 3928 <textarea id="doc-editor-textarea" class="doc-editor-textarea" placeholder="Document content..." spellcheck="false"></textarea> 3929 </div> 3930 <!-- WYSIWYG email body. In email mode this replaces the source editor: 3931 B/I/S act on the live text (execCommand), and on send its HTML becomes 3932 the email's HTML part. Its plain text is mirrored into the textarea so 3933 the existing send/draft/change-detection paths keep working. --> 3934 <div id="doc-email-richbody" class="doc-email-richbody" contenteditable="true" spellcheck="true" style="display:none" data-no-swipe-dismiss></div> 3935 <div id="doc-email-actions" class="doc-email-actions" style="display:none"> 3936 <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> 3937 <span style="flex:1"></span> 3938 <div class="email-send-split"> 3939 <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> 3940 <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> 3941 <div id="doc-email-more-menu" class="email-more-menu" style="display:none"> 3942 <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> 3943 <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> 3944 <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> 3945 </div> 3946 </div> 3947 </div> 3948 <div id="doc-md-preview" class="doc-md-preview" style="display:none"></div> 3949 <div id="doc-csv-preview" class="doc-csv-preview" style="display:none"></div> 3950 <iframe id="doc-html-preview" class="doc-html-preview" sandbox="allow-scripts allow-modals" style="display:none"></iframe> 3951 <div id="doc-pdf-view" style="display:none;width:100%;flex:1;min-height:0;overflow:auto;background:#525659;padding:20px 0;position:relative;"> 3952 <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> 3953 </div> 3954 <!-- Action footer sits AFTER all the content/preview panes so it stays 3955 pinned to the bottom no matter which pane (editor / md-
3955preview / 3956 csv / html / pdf) is the one growing to fill. --> 3957 <div id="doc-actions-footer" class="doc-email-actions"> 3958 <span class="email-send-split" id="doc-copy-export-split"> 3959 <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> 3960 <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> 3961 </span> 3962 </div> 3963 <div id="doc-version-panel" class="doc-version-panel hidden"> 3964 <div class="doc-version-header"> 3965 <span>Version History</span> 3966 <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> 3967 </div> 3968 <div id="doc-version-list" class="doc-version-list"></div> 3969 </div> 3970 <div id="doc-mobile-footer" class="doc-mobile-footer"> 3971 <button id="doc-mobile-close" class="doc-mobile-footer-btn" type="button">Unlink</button> 3972 <span style="flex:1"></span> 3973 <button id="doc-mobile-copy" class="doc-mobile-footer-btn" type="button">Copy</button> 3974 </div> 3975 `; 3976 3977 // Consolidate into a SINGLE action bar: move Undo + the type picker out of 3978 // the top header into the bottom footer (left side, next to Close) so a 3979 // regular doc shows one bar, not two. The rest of the header (run/preview, 3980 // fullscreen, version, PDF) stays put; the header hides itself when nothing 3981 // in it is visible â see _syncHeaderBarVisibility(). 3982 // Note: `#doc-render-view-toggle` (codeârun for SVG/HTML) intentionally 3983 // stays in the top header so it matches `#doc-md-view-toggle` (markdown 3984 // editâpreview) â both view toggles live in the same place. 3985 { 3986 const _footer = pane.querySelector('#doc-actions-footer'); 3987 const _split = _footer && _footer.querySelector('#doc-copy-export-split'); 3988 const _undo = pane.querySelector('#doc-undo-btn'); 3989 const _lang = pane.querySelector('#doc-language-select'); 3990 const _preview = pane.querySelector('#doc-header-preview-btn'); // single Run â¶ for python/bash/js/csv 3991 const _exportPdf = pane.querySelector('#doc-export-pdf-btn'); 3992 const _pdfView = pane.querySelector('#doc-pdf-view-btn'); 3993 if (_footer && _split) { 3994 // Footer order (left â right): Undo, Run/Preview, Lang, â¦, Copy/Export. 3995 // The X close was here too but is now redundant with the per-tab close 3996 // button in the title strip â removed. 3997 if (_undo) _footer.insertBefore(_undo, _footer.firstChild); 3998 const _anchor = _undo; 3999 if (_preview && _anchor) _anchor.after(_preview); 4000 if (_lang) _split.before(_lang); 4001 // Pull every remaining header-only control into the footer so we 4002 // only ever render ONE bottom action row. The standalone top header 4003 // was leaving a duplicate row above (with fullscreen + version badge 4004 // + stream indicator). Each item keeps its own display: toggling. 4005 const _streamInd = pane.querySelector('#doc-stream-indicator'); 4006 const _versionBadge = pane.querySelector('#doc-version-badge'); 4007 if (_split) { 4008 if (_pdfView) _split.before(_pdfView); 4009 if (_exportPdf) _split.before(_exportPdf); 4010 if (_versionBadge) _split.before(_versionBadge); 4011 if (_streamInd) _split.before(_streamInd); 4012 } 4013 } 4014 // iOS keeps the soft keyboard up when you tap a <button> (it doesn't blur 4015 // the focused textarea), so it lingers after you've typed. Dismiss it on 4016 // any footer control tap. 4017 if (_footer) _footer.addEventListener('pointerdown', (e) => { 4018 if (!e.target.closest('button, select')) return; 4019 const _ta = document.getElementById('doc-editor-textarea'); 4020 if (_ta && document.activeElement === _ta) _ta.blur(); 4021 }); 4022 } 4023 4024 // Insert after chat-container (appears on right by default) 4025 // If sidebar is on the right, insert before chat-container instead 4026 const sidebar = document.getElementById('sidebar'); 4027 const isRight = sidebar && sidebar.classList.contains('right-side'); 4028 if (isRight) { 4029 pane.classList.add('doc-left'); 4030 container.parentNode.insertBefore(pane, container); 4031 container.parentNode.insertBefore(divider, container); 4032 } else { 4033 pane.classList.remove('doc-left'); 4034 container.after(divider); 4035 divider.after(pane); 4036 } 4037 4038 // Slide-in animation from the correct side 4039 const fromLeft = pane.classList.contains('doc-left'); 4040 pane.style.transform = fromLeft ? 'translateX(-40px)' : 'translateX(40px)'; 4041 pane.style.opacity = '0'; 4042 requestAnimationFrame(() => { 4043 pane.style.transition = 'transform 0.15s cubic-bezier(0.22,1,0.36,1), opacity 0.12s ease-out'; 4044 pane.style.transform = 'translateX(0)'; 4045 pane.style.opacity = '1'; 4046 pane.addEventListener('transitionend', () => { 4047 pane.style.transition = ''; 4048 pane.style.transform = ''; 4049 pane.style.opacity = ''; 4050 }, { once: true }); 4051 }); 4052 4053 // Wire up divider drag to resize 4054 initDividerDrag(divider, pane, isRight); 4055 // Divider chevron â single button with three modes (the glyph is the 4056 // same `âº` in markup; CSS rotates 180° for the left-pointing variant). 4057 // ⢠cursor INSIDE the doc pane â collapse (âº, slide back, closes panel) 4058 // ⢠cursor OUTSIDE the doc pane â fullscreen (â¹, slide outward, expands) 4059 // ⢠already fullscreen â unfullscreen (âº, points back in) 4060 // The user can also drag the chevron vertically along the divider to 4061 // reposition it. 4062 const _divCollapse = divider.querySelector('.doc-divider-collapse'); 4063 if (_divCollapse) { 4064 _divCollapse.addEventListener('mousedown', (e) => e.stopPropagation()); 4065 let _dragging = false;
4066 _divCollapse.addEventListener('click', (e) => { 4067 e.stopPropagation(); 4068 if (_dragging) { _dragging = false; return; } // suppress click after drag 4069 const mode = _divCollapse.dataset.mode; 4070 if (mode === 'fullscreen' || mode === 'unfullscreen') toggleFullscreen(); 4071 else closePanel('down'); 4072 }); 4073 const HYSTERESIS = 24; 4074 const _applyMode = (ev) => { 4075 // Fullscreen state takes precedence â once the pane is fullscreen the 4076 // chevron always offers the "exit fullscreen" affordance regardless 4077 // of cursor position. 4078 const isFull = pane.classList.contains('doc-fullscreen'); 4079 if (isFull) { 4080 if (_divCollapse.dataset.mode !== 'unfullscreen') { 4081 _divCollapse.dataset.mode = 'unfullscreen'; 4082 _divCollapse.title = 'Exit fullscreen'; 4083 } 4084 return; 4085 } 4086 if (!ev) return; 4087 const rect = divider.getBoundingClientRect(); 4088 const midX = (rect.left + rect.right) / 2; 4089 const cur = _divCollapse.dataset.mode; 4090 if (ev.clientX > midX + HYSTERESIS && cur !== 'collapse') { 4091 _divCollapse.dataset.mode = 'collapse'; 4092 _divCollapse.title = 'Collapse panel'; 4093 } else if (ev.clientX < midX - HYSTERESIS && cur !== 'fullscreen') { 4094 _divCollapse.dataset.mode = 'fullscreen'; 4095 _divCollapse.title = 'Fullscreen'; 4096 } 4097 }; 4098 const _onMove = (ev) => _applyMode(ev); 4099 document.addEventListener('pointermove', _onMove, { passive: true }); 4100 // Reflect the fullscreen state immediately on toggle (no cursor move). 4101 const _classObs = new MutationObserver(() => _applyMode()); 4102 _classObs.observe(pane, { attributes: true, attributeFilter: ['class'] }); 4103 4104 // Drag-to-reposition: hold + drag vertically moves the chevron along 4105 // the divider. Stored as a percent so resizing the pane keeps it
4106 // proportional. Only kicks in after a small movement so a normal tap 4107 // still registers as a click. 4108 const DRAG_THRESHOLD = 4; 4109 let _startY = 0, _moved = false, _pid = null; 4110 _divCollapse.addEventListener('pointerdown', (ev) => { 4111 if (ev.button !== 0 && ev.pointerType === 'mouse') return; 4112 _startY = ev.clientY; 4113 _moved = false; 4114 _pid = ev.pointerId; 4115 _divCollapse.setPointerCapture?.(_pid); 4116 ev.preventDefault(); 4117 }); 4118 _divCollapse.addEventListener('pointermove', (ev) => { 4119 if (_pid === null) return; 4120 const dy = ev.clientY - _startY; 4121 if (!_moved && Math.abs(dy) < DRAG_THRESHOLD) return; 4122 _moved = true; 4123 _dragging = true; 4124 const rect = divider.getBoundingClientRect(); 4125 if (!rect.height) return; 4126 const pct = Math.max(6, Math.min(94, ((ev.clientY - rect.top) / rect.height) * 100)); 4127 _divCollapse.style.top = pct + '%'; 4128 }); 4129 const _endDrag = () => { 4130 if (_pid !== null) { 4131 try { _divCollapse.releasePointerCapture?.(_pid); } catch {} 4132 _pid = null; 4133 } 4134 }; 4135 _divCollapse.addEventListener('pointerup', _endDrag); 4136 _divCollapse.addEventListener('pointercancel', _endDrag); 4137 4138 const _obs = new MutationObserver(() => { 4139 if (!document.body.contains(divider)) { 4140 document.removeEventListener('pointermove', _onMove); 4141 _classObs.disconnect(); 4142 _obs.disconnect(); 4143 } 4144 }); 4145 _obs.observe(document.body, { childList: true, subtree: true }); 4146 } 4147 4148 // Mobile grab handle â swipe down to dismiss (like the other sheet windows). 4149 _wireSwipeDismiss(document.getElementById('doc-mobile-grabber')); 4150 document.getElementById('doc-mobile-grabber')?.addEventListener('click', () => closePanel('down')); 4151 4152 // Wire up events 4153 document.getElementById('doc-close-btn')?.addEventListener('click', () => closePanel('down')); 4154 document.getElementById('doc-footer-close-btn')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); }); 4155 document.getElementById('doc-import-btn')?.addEventListener('click', () => openLibrary()); 4156 document.getElementById('doc-footer-copy-btn')?.addEventListener('click', (e) => { 4157 if (e.currentTarget.dataset.mode === 'reply') { if (activeDocId) _sendSignedReply(activeDocId); } 4158 else copyDocument(); 4159 }); 4160 document.getElementById('doc-footer-export-btn')?.addEventListener('click', (e) => showExportMenu(null, e.currentTarget.getBoundingClientRect())); 4161 // Mobile footer: Close the current doc + Copy its content (replaces the 4162 // per-tab à on small screens, mirroring the email reader's Close footer). 4163 document.getElementById('doc-mobile-close')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); }); 4164 document.getElementById('doc-mobile-copy')?.addEventListener('click', () => copyDocument()); 4165 // Save, copy, run, export, delete, preview toggles are now in per-tab context menu 4166 document.getElementById('doc-version-badge').addEventListener('click', toggleVersionHistory); 4167 document.getElementById('doc-version-close').addEventListener('click', _closeVersionPanel); 4168 // Reflect the current language as a small icon left of the type select. 4169 const _syncLangIcon = () => { 4170 const iconEl = document.getElementById('doc-language-icon'); 4171 const v = document.getElementById('doc-language-select')?.value || ''; 4172 if (iconEl) iconEl.innerHTML = v ? langIcon(v, 14, { style: 'opacity:0.75;' }) : ''; 4173 }; 4174 // Intercept programmatic `langSelect.value = â¦` so the icon updates without 4175 // having to instrument every set-site in this file. 4176 (function _interceptLangSelectValue() { 4177 const ls = document.getElementById('doc-language-select'); 4178 if (!ls) return; 4179 const desc = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); 4180 if (!desc || !desc.set) return; 4181 Object.defineProperty(ls, 'value', { 4182 configurable: true, 4183 get() { return desc.get.call(this); }, 4184 set(v) { desc.set.call(this, v); _syncLangIcon(); _syncLangPicker(); }, 4185 }); 4186 _syncLangIcon(); // initial paint 4187 })(); 4188 4189 // ââ Custom language picker ââââââââââââââââââââââââââââââââââââââââââââ 4190 // Native <option> can't render SVG. So we build a custom dropdown that 4191 // shows each language's icon + label, while keeping the underlying 4192 // <select> in place as the source of truth (all existing code that 4193 // reads/writes langSelect.value keeps working). The native select is 4194 // visually-hidden but still focusable for accessibility / keyboard. 4195 let _syncLangPicker = () => {}; 4196 (function _initLangPicker() { 4197 const ls = document.getElementById('doc-language-select'); 4198 if (!ls || ls.dataset.pickerWired === '1') return; 4199 ls.dataset.pickerWired = '1'; 4200 4201 const trigger = document.createElement('button'); 4202 trigger.type = 'button'; 4203 trigger.id = 'doc-langpicker-trigger'; 4204 trigger.className = 'doc-langpicker-trigger'; 4205 trigger.setAttribute('aria-haspopup', 'listbox'); 4206 trigger.setAttribute('aria-expanded', 'false'); 4207 4208 const menu = document.createElement('div'); 4209 menu.id = 'doc-langpicker-menu'; 4210 menu.className = 'doc-langpicker-menu'; 4211 menu.setAttribute('role', 'listbox'); 4212 menu.style.display = 'none'; 4213 4214 // Build the menu rows from the <select>'s real <option>s â single 4215 // source of truth, future additions to the select auto-propagate. 4216 const _buildMenu = () => { 4217 menu.innerHTML = ''; 4218 for (const opt of ls.options) {
4219 const row = document.createElement('button'); 4220 row.type = 'button'; 4221 row.className = 'doc-langpicker-item'; 4222 row.dataset.value = opt.value; 4223 row.setAttribute('role', 'option'); 4224 const ic = opt.value 4225 ? langIcon(opt.value, 14, { style: 'opacity:0.85;' }) 4226 // Empty value = the "type" placeholder option â small dot so the 4227 // row still aligns with the others (and the picker shows _some_ 4228 // mark when no type is set yet). 4229 : '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.5;"><circle cx="12" cy="12" r="3"/></svg>'; 4230 row.innerHTML = ic + 4231 `<span class="doc-langpicker-label">${uiModule.esc(opt.textContent || opt.value)}</span>`; 4232 row.addEventListener('click', (e) => { 4233 e.stopPropagation(); 4234 if (ls.value !== opt.value) { 4235 ls.value = opt.value; 4236 ls.dispatchEvent(new Event('change', { bubbles: true })); 4237 } 4238 _close(); 4239 }); 4240 menu.appendChild(row); 4241 } 4242 }; 4243 _buildMenu(); 4244 4245 _syncLangPicker = () => { 4246 const v = ls.value || ''; 4247 const sel = Array.from(ls.options).find(o => o.value === v) || ls.options[0]; 4248 const ic = v 4249 ? langIcon(v, 14, { style: 'opacity:0.85;flex-shrink:0;' }) 4250 // No language picked yet â small dot mark so the trigger isn't bare. 4251 : '<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>'; 4252 trigger.innerHTML = ic + 4253 `<span class="doc-langpicker-label">${uiModule.esc(sel?.textContent || 'type')}</span>` + 4254 '<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>'; 4255 // Highlight the current row in the open menu. 4256 menu.querySelectorAll('.doc-langpicker-item').forEach(r => { 4257 r.classList.toggle('is-selected', r.dataset.value === v); 4258 }); 4259 }; 4260 4261 const _close = () => { 4262 menu.style.display = 'none'; 4263 trigger.setAttribute('aria-expanded', 'false'); 4264 document.removeEventListener('click', _outsideClick, true); 4265 document.removeEventListener('keydown', _escKey, true); 4266 }; 4267 const _outsideClick = (e) => { 4268 if (!menu.contains(e.target) && e.target !== trigger) _close(); 4269 }; 4270 const _escKey = (e) => { 4271 if (e.key !== 'Escape' || menu.style.display === 'none') return; 4272 e.preventDefault(); 4273 e.stopPropagation(); 4274 e.stopImmediatePropagation?.(); 4275 _close(); 4276 }; 4277 4278 trigger.addEventListener('click', (e) => { 4279 e.stopPropagation(); 4280 const open = menu.style.display !== 'none'; 4281 if (open) { _close(); return; } 4282 // Position the menu under the trigger (fixed so it escapes any 4283 // overflow-clipped ancestor like the footer). 4284 const r = trigger.getBoundingClientRect(); 4285 menu.style.display = 'block'; 4286 menu.style.position = 'fixed'; 4287 menu.style.left = r.left + 'px'; 4288 menu.style.top = (r.bottom + 4) + 'px'; 4289 menu.style.minWidth = r.width + 'px'; 4290 // If it would overflow the bottom of the viewport, flip above. 4291 requestAnimationFrame(() => { 4292 const mr = menu.getBoundingClientRect(); 4293 if (mr.bottom > window.innerHeight - 8) { 4294 menu.style.top = Math.max(8, r.top - mr.height - 4) + 'px'; 4295 } 4296 }); 4297 trigger.setAttribute('aria-expanded', 'true'); 4298 document.addEventListener('click', _outsideClick, true); 4299 document.addEventListener('keydown', _escKey, true); 4300 }); 4301 4302 // Hide the native select but keep it in the layout for screen readers 4303 // / programmatic value sets / focus management. The icon span next to 4304 // it is removed since the trigger now carries the current icon. 4305 ls.classList.add('doc-langpicker-native-hidden'); 4306 const iconSpan = document.getElementById('doc-language-icon'); 4307 if (iconSpan) iconSpan.remove(); 4308 ls.parentNode.insertBefore(trigger, ls); 4309 // Menu is body-mounted so position:fixed coords work cleanly. 4310 document.body.appendChild(menu); 4311 4312 _syncLangPicker(); 4313 })(); 4314 document.getElementById('doc-language-select').addEventListener('change', () => { 4315 _syncLangIcon(); 4316 _syncLangPicker(); 4317 const val = document.getElementById('doc-language-select').value; 4318 // For form-backed docs, the select toggles between PDF view and the 4319 // markdown source instead of changing the underlying language. 4320 const live = document.getElementById('doc-editor-textarea')?.value 4321 || docs.get(activeDocId)?.content || ''; 4322 if (_isFormBackedDoc(live) && (val === 'pdf' || val === 'markdown')) { 4323 _setPdfViewActive(val === 'pdf'); 4324 return; 4325 } 4326 // Mark user explicitly chose a language â stop auto-detection 4327 if (activeDocId && docs.has(activeDocId)) { 4328 docs.get(activeDocId).userSetLanguage = (val !== ''); 4329 } 4330 updateLanguage();
4331 syncHighlighting(); 4332 // Show/hide markdown toolbar 4333 const lang = document.getElementById('doc-language-select').value; 4334 const mdToolbar = document.getElementById('doc-md-toolbar'); 4335 if (mdToolbar) { 4336 // Toolbar stays visible for every type now; only the items inside 4337 // gate themselves on language. 4338 mdToolbar.style.display = ''; 4339 if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); 4340 } 4341 // If switching away from markdown, exit preview 4342 if (lang !== 'markdown') { 4343 _setMarkdownPreviewActive(false); 4344 } 4345 // If switching away from CSV, exit table preview 4346 if (lang !== 'csv') { 4347 const csvPreview = document.getElementById('doc-csv-preview'); 4348 const wrap2 = document.getElementById('doc-editor-wrap'); 4349 if (csvPreview) csvPreview.style.display = 'none'; 4350 if (wrap2) wrap2.style.display = ''; 4351 } 4352 // If switching away from html, exit HTML preview 4353 if (!_isRenderLang(lang)) exitHtmlPreview(); 4354 // Show/hide email fields 4355 if (lang === 'email') { 4356 const doc = activeDocId && docs.get(activeDocId); 4357 if (doc) _showEmailFields(doc); 4358 } else { 4359 _hideEmailFields(); 4360 } 4361 // Sync header action buttons for new language 4362 _syncHeaderActions(); 4363 }); 4364 4365 // Email send/draft buttons 4366 // Inject emoji picker button into markdown toolbar 4367 const emojiSlot = document.getElementById('md-toolbar-emoji-slot'); 4368 if (emojiSlot && !emojiSlot.querySelector('.emoji-picker-btn')) { 4369 // Resolve the live target on click: the WYSIWYG email contenteditable 4370 // when active, otherwise the plain markdown textarea. 4371 emojiSlot.appendChild(emojiPicker.createEmojiButton( 4372 () => _emailRichbodyActive() || document.getElementById('doc-editor-textarea') 4373 )); 4374 } 4375 4376 document.getElementById('doc-email-send-btn')?.addEventListener('click', () => { 4377 // Pressing Send must never leave the "more options" menu showing. 4378 const _m = document.getElementById('doc-email-more-menu'); 4379 if (_m) _m.style.display = 'none'; 4380 document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false'); 4381 _sendEmail(); 4382 }); 4383 4384 // Ctrl+Enter / Cmd+Enter sends the email when an email doc is active 4385 // Bind once at module level via a guard to avoid duplicate listeners on re-open 4386 if (!window._emailCtrlEnterBound) { 4387 window._emailCtrlEnterBound = true; 4388 document.addEventListener('keydown', (e) => { 4389 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { 4390 const doc = activeDocId && docs.get(activeDocId); 4391 if (doc && doc.language === 'email' && isOpen) { 4392 e.preventDefault(); 4393 _sendEmail(); 4394 } 4395 } 4396 }); 4397 } 4398 document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => { 4399 document.getElementById('doc-email-more-menu').style.display = 'none'; 4400 _saveDraft(); 4401 }); 4402 document.getElementById('doc-email-discard-btn')?.addEventListener('click', _discardEmail); 4403 document.getElementById('doc-email-unread-btn')?.addEventListener('click', () => { 4404 document.getElementById('doc-email-more-menu').style.display = 'none'; 4405 _markUnreadAndClose(); 4406 }); 4407 document.getElementById('doc-email-schedule-btn')?.addEventListener('click', (e) => { 4408 const anchor = document.getElementById('doc-email-send-caret') || e.currentTarget; 4409 document.getElementById('doc-email-more-menu').style.display = 'none'; 4410 _scheduleSend(anchor); 4411 }); 4412 document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply); 4413 4414 const collapseBtn = document.getElementById('doc-email-collapse-btn'); 4415 if (collapseBtn && !collapseBtn._emailCollapseWired) { 4416 collapseBtn._emailCollapseWired = true; 4417 collapseBtn.addEventListener('pointerdown', (e) => { 4418 e.preventDefault(); 4419 e.stopPropagation(); 4420 const focusState = _captureEmailBodyFocusState(); 4421 const header = document.getElementById('doc-email-header'); 4422 const nextCollapsed = !header?.classList.contains('doc-email-header-collapsed'); 4423 _setEmailHeaderCollapsed(nextCollapsed); 4424 if (!nextCollapsed) _restoreEmailBodyFocusState(focusState); 4425 }); 4426 collapseBtn.addEventListener('click', (e) => { 4427 e.preventDefault(); 4428 e.stopPropagation(); 4429 }); 4430 } 4431 ['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => { 4432 document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary); 4433 document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false })); 4434 }); 4435 document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader);
4436 if (window.visualViewport && !window._docEmailViewportCollapseBound) { 4437 window._docEmailViewportCollapseBound = true; 4438 window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader); 4439 } 4440 4441 // Split-button caret toggles the send-options menu (drops up). 4442 document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => { 4443 e.stopPropagation(); 4444 const menu = document.getElementById('doc-email-more-menu'); 4445 const caret = document.getElementById('doc-email-send-caret'); 4446 if (!menu) return; 4447 const opening = menu.style.display === 'none'; 4448 menu.style.display = opening ? '' : 'none'; 4449 if (caret) caret.setAttribute('aria-expanded', String(opening)); 4450 }); 4451 document.addEventListener('click', (e) => { 4452 const menu = document.getElementById('doc-email-more-menu'); 4453 // Keep the menu open ONLY while interacting with the caret itself or the 4454 // menu. Any other click â including the Send button (which sits in the 4455 // same .email-send-split) â closes it, so the popup is tied to the arrow. 4456 if (menu && !e.target.closest('#doc-email-send-caret, #doc-email-more-menu')) { 4457 menu.style.display = 'none'; 4458 document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false'); 4459 } 4460 }); 4461 document.addEventListener('keydown', (e) => { 4462 if (e.key !== 'Escape') return; 4463 const menu = document.getElementById('doc-email-more-menu'); 4464 if (!menu || menu.style.display === 'none') return; 4465 e.preventDefault(); 4466 e.stopPropagation(); 4467 e.stopImmediatePropagation?.(); 4468 menu.style.display = 'none'; 4469 document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false'); 4470 }, true); 4471 4472 // Attachments 4473 document.getElementById('doc-email-attach-btn')?.addEventListener('click', () => { 4474 document.getElementById('doc-email-file-input')?.click(); 4475 }); 4476 document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => { 4477 document.getElementById('doc-email-file-input')?.click(); 4478 }); 4479 document.getElementById('doc-email-file-input')?.addEventListener('change', _handleAttachUpload); 4480 4481 // Cc/Bcc toggle 4482 document.getElementById('doc-email-show-cc')?.addEventListener('click', () => { 4483 _setEmailHeaderCollapsed(false, { manual: false }); 4484 const ccRow = document.getElementById('doc-email-cc-row'); 4485 const bccRow = document.getElementById('doc-email-bcc-row'); 4486 if (ccRow) ccRow.style.display = ''; 4487 if (bccRow) bccRow.style.display = ''; 4488 document.getElementById('doc-email-show-cc').style.display = 'none'; 4489 _syncEmailHeaderSummary(); 4490 }); 4491 4492 // Autocomplete for To / Cc / Bcc â typed fragment after the last 4493 // comma triggers contact search; Enter / Tab / click on a suggestion 4494 // appends "<email>, " so the user can keep typing more recipients. 4495 _wireRecipientAutocomplete('doc-email-to', 'doc-email-to-suggestions'); 4496 _wireRecipientAutocomplete('doc-email-cc', 'doc-email-cc-suggestions'); 4497 _wireRecipientAutocomplete('doc-email-bcc', 'doc-email-bcc-suggestions'); 4498 4499 // Header unified action button (preview or run depending on language) 4500 document.getElementById('doc-header-preview-btn').addEventListener('click', () => { 4501 const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); 4502 if (lang === 'markdown') toggleMarkdownPreview(); 4503 else if (lang === 'csv') toggleCsvPreview(); 4504 else if (_isRenderLang(lang)) toggleHtmlPreview(); 4505 else { 4506 // Runnable language â toggle output 4507 const outputPanel = document.getElementById('doc-run-output'); 4508 if (outputPanel && outputPanel.style.display !== 'none') { 4509 outputPanel.style.display = 'none'; 4510 } else { 4511 runDocument(); 4512 } 4513 } 4514 _syncHeaderActions(); 4515 }); 4516 4517 // Markdown Edit/Preview two-icon switch â click a side to go to that view. 4518 document.getElementById('doc-md-view-toggle')?.addEventListener('click', (e) => { 4519 const opt = e.target.closest('.md-view-opt'); 4520 if (!opt) return; 4521 const wantPreview = opt.dataset.mdview === 'preview'; 4522 const mdPrev = document.getElementById('doc-md-preview'); 4523 const isPreview = mdPrev && mdPrev.style.display !== 'none'; 4524 if (wantPreview !== isPreview) toggleMarkdownPreview(); 4525 _syncHeaderActions(); 4526 }); 4527 4528 // Unified Code / Run-or-View two-icon switch â language-aware: CSV flips 4529 // between code and the table view, Python/JS/etc. between code and run 4530 // output, HTML/SVG/XML between code and the iframe preview. 4531 document.getElementById('doc-render-view-toggle')?.addEventListener('click', (e) => { 4532 const opt = e.target.closest('.md-view-opt'); 4533 if (!opt) return; 4534 const wantRun = opt.dataset.renderview === 'run'; 4535 const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); 4536 if (lang === 'csv') { 4537 const csv = document.getElementById('doc-csv-preview'); 4538 const isOn = csv && csv.style.display !== 'none'; 4539 if (wantRun !== isOn) toggleCsvPreview(); 4540 } else if (_isRenderLang(lang)) { 4541 const htmlPrev = document.getElementById('doc-html-preview'); 4542 const isOn = htmlPrev && htmlPrev.style.display !== 'none'; 4543 if (wantRun !== isOn) toggleHtmlPreview(); 4544 } else { 4545 // Runnable language (python / js / ts / bash â¦) â clicking Run is 4546 // a one-shot execute; clicking Code dismisses the output pane. 4547 if (wantRun) { 4548 document.getElementById('doc-header-preview-btn')?.click(); 4549 } else { 4550 const out = document.getElementById('doc-run-output'); 4551 if (out) out.style.display = 'none'; 4552 } 4553 } 4554 _syncHeaderActions(); 4555 }); 4556 4557 // Font size toggle (S â M â L) 4558 const fontBtn = document.getElementById('doc-fontsize-btn'); 4559 const editorWrap = document.getElementById('doc-editor-wrap'); 4560 const _fontSizes = ['s', 'm', 'l']; 4561 const _iconSizes = [12, 14, 16]; 4562 let _fontIdx = parseInt(localStorage.getItem('odysseus-doc-fontsize') || '0', 10); 4563 if (!(_fontIdx >= 0 && _fontIdx < 3)) _fontIdx = 0; 4564 function _applyDocFont() { 4565 const richEmailBody = document.getElementById('doc-email-richbody');
4566 [editorWrap, richEmailBody].filter(Boolean).forEach(el => { 4567 el.classList.remove('doc-font-s', 'doc-font-m', 'doc-font-l'); 4568 if (_fontSizes[_fontIdx] !== 's') el.classList.add('doc-font-' + _fontSizes[_fontIdx]); 4569 }); 4570 if (fontBtn) { 4571 fontBtn.dataset.size = _fontSizes[_fontIdx]; 4572 // Keep the original behaviour: the icon itself grows with the size. 4573 const svg = fontBtn.querySelector('svg'); 4574 if (svg) { const sz = _iconSizes[_fontIdx]; svg.setAttribute('width', sz); svg.setAttribute('height', sz); } 4575 // Show only the active size letter (just S, or just M, or just L). 4576 fontBtn.querySelectorAll('.doc-fontsize-levels [data-sz]').forEach(el => { 4577 const active = el.dataset.sz === _fontSizes[_fontIdx]; 4578 el.classList.toggle('active', active); 4579 el.style.display = active ? '' : 'none'; 4580 }); 4581 } 4582 localStorage.setItem('odysseus-doc-fontsize', _fontIdx); 4583 } 4584 _applyDocFont(); 4585 // Click cycles through the sizes (S â M â L â S). 4586 if (fontBtn) fontBtn.addEventListener('click', () => { 4587 _fontIdx = (_fontIdx + 1) % 3; 4588 _applyDocFont(); 4589 syncHighlighting(); 4590 }); 4591 4592 // Undo button in header 4593 const docUndoBtn = document.getElementById('doc-undo-btn'); 4594 if (docUndoBtn) docUndoBtn.addEventListener('click', async () => { 4595 const pdfPane = document.getElementById('doc-pdf-view'); 4596 const pdfVisible = pdfPane && pdfPane.style.display !== 'none'; 4597 if (pdfVisible && await _undoPdfPaneAction()) return; 4598 const ta = document.getElementById('doc-editor-textarea'); 4599 if (ta) { 4600 ta.focus(); // execCommand('undo') needs the textarea focused 4601 document.execCommand('undo'); 4602 _dismissDocKb(); // then force the keyboard back down on touch 4603 } 4604 }); 4605 4606 // Diff toggle button â compare current content against previous version 4607 const diffToggleBtn = document.getElementById('doc-diff-toggle-btn'); 4608 if (diffToggleBtn) diffToggleBtn.addEventListener('click', async () => { 4609 if (_diffModeActive) { 4610 exitDiffMode(true); 4611 return; 4612 } 4613 if (!activeDocId) return; 4614 const ta = document.getElementById('doc-editor-textarea'); 4615 if (!ta) return; 4616 const current = ta.value; 4617 4618 // Fetch version history and compare against previous version 4619 try { 4620 const res = await fetch(`${API_BASE}/api/document/${activeDocId}/versions`); 4621 if (!res.ok) throw new Error('Failed'); 4622 const versions = await res.json(); 4623 if (versions.length < 2) { 4624 if (uiModule) uiModule.showToast('No previous version to compare'); 4625 return; 4626 } 4627 // versions are sorted desc â [0] is latest, [1] is previous 4628 const prevContent = versions[1].content || ''; 4629 if (prevContent === current) { 4630 if (uiModule) uiModule.showToast('No changes from previous version'); 4631 return; 4632 } 4633 enterDiffMode(prevContent, current); 4634 } catch { 4635 if (uiModule) uiModule.showError('Failed to load version history'); 4636 } 4637 }); 4638 4639 // Export PDF (form-backed markdown docs) 4640 document.getElementById('doc-export-pdf-btn')?.addEventListener('click', _downloadFilledPdf); 4641 4642 // Toggle inline PDF view (form-backed markdown docs). Default for a 4643 // form-backed doc is "active" â the toggle reads back the visible state. 4644 document.getElementById('doc-pdf-view-btn')?.addEventListener('click', () => { 4645 const pane = document.getElementById('doc-pdf-view'); 4646 const visible = pane && pane.style.display !== 'none'; 4647 _setPdfViewActive(!visible); 4648 }); 4649 4650 // Toolbar buttons toggle: clicking the active mode clears it. Otherwise 4651 // the mode stays armed across multiple placements until the user turns 4652 // it off explicitly. 4653 document.getElementById('doc-pdf-add-text-btn')?.addEventListener('click', () => _setPdfDropMode(_pdfDropMode === 'text' ? null : 'text')); 4654 document.getElementById('doc-pdf-add-check-btn')?.addEventListener('click', () => _setPdfDropMode(_pdfDropMode === 'check' ? null : 'check')); 4655 document.getElementById('doc-pdf-add-sign-btn')?.addEventListener('click', () => _setPdfDropMode(_pdfDropMode === 'signature' ? null : 'signature')); 4656 document.getElementById('doc-pdf-refresh-btn')?.addEventListener('click', () => _renderPdfPane()); 4657 4658 // Markdown formatting toolbar 4659 initMdToolbar(); 4660 4661 // Wire highlighting sync 4662 const ta = document.getElementById('doc-editor-textarea'); 4663 const pre = document.getElementById('doc-editor-highlight'); 4664 if (ta && pre) { 4665 ta.addEventListener('input', () => { 4666 // Typing invalidates any pinned selection highlight 4667 if (_selections.length) clearSelection(); 4668 // Auto-create a document if user types/pastes with no active doc. 4669 // Skip while a createDocument POST is in flight â otherwise typing 4670 // during the round-trip spawns a duplicate untitled doc. 4671 if (!activeDocId && !_creatingDoc && ta.value.trim()) { 4672 _autoCreateFromInput(ta.value); 4673 return; 4674 } 4675 // Sync text content immediately (prevents visual duplication from scroll desync) 4676 const codeEl = document.getElementById('doc-editor-code'); 4677 if (codeEl && !codeEl.dataset.hasDiff) { 4678 codeEl.textContent = ta.value + '\n'; 4679 codeEl.style.minHeight = ta.scrollHeight + 'px'; 4680 } 4681 if (pre) { 4682 pre.scrollTop = ta.scrollTop; 4683 pre.scrollLeft = ta.scrollLeft; 4684 } 4685 updateLineNumbers(ta.value); 4686 // Debounce expensive operations (syntax highlighting, auto-detect, auto-save) 4687 clearTimeout(_hlDebounce); 4688 _hlDebounce = setTimeout(syncHighlighting, 80); 4689 clearTimeout(_autoDetectDebounce); 4690 _autoDetectDebounce = setTimeout(attemptAutoDetect, AUTO_DETECT_DELAY); 4691 clearTimeout(_autoTitleDebounce); 4692 _autoTitleDebounce = setTimeout(() => autoTitleFromContent(ta.value), 600); 4693 clearTimeout(_autoSaveDebounce); 4694 _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000); 4695 }); 4696 ta.addEventListener('scroll', () => { 4697 const code = document.getElementById('doc-editor-code'); 4698 if (code) code.style.minHeight = ta.scrollHeight + 'px'; 4699 pre.scrollTop = ta.scrollTop; 4700 pre.scrollLeft = ta.scrollLeft; 4701 syncGutterScroll(); 4702 syncSelectionOverlay(); 4703 // Re-position find rects so they track the textarea on scroll. 4704 if (_findMatches && _findMatches.length) { 4705 const _q = document.getElementById('doc-find-input')?.value || ''; 4706 if (_q) renderFindRects(_findMatches.map(s => [s, s + _q.length]), _findIdx); 4707 } 4708 }); 4709 // Tab key inserts a real tab; Escape clears selection 4710 ta.addEventListener('keydown', (e) => { 4711 if (e.key === 'Escape') { 4712 if (_diffModeActive) { exitDiffMode(true); return; } 4713 // First Esc clears any pinned selection without closing the 4714 // panel. Second Esc (no selection left) minimises the panel 4715 // to a dock chip. The previous all-in-one path made one Esc 4716 // press both clear and close, which was annoying because the 4717 // user lost their working doc by hitting Esc to dismiss a 4718 // mistaken highlight. 4719 if (_selections.length > 0) { 4720 clearSelection(); 4721 e.preventDefault(); 4722 e.stopPropagation(); 4723 return; 4724 } 4725 // No pinned selection â Esc MINIMIZES the panel (tabs it 4726 // down to a dock chip) â same as the chevron button. 4727 e.preventDefault(); 4728 e.stopPropagation(); 4729 closePanel('down'); 4730 return; 4731 } 4732 if (e.key === 'Tab') { 4733 e.preventDefault(); 4734 document.execCommand('insertText', false, '\t'); 4735 } 4736 // Markdown shortcuts (only when language is markdown) 4737 const lang = document.getElementById('doc-language-select')?.value; 4738 if (lang === 'markdown' && (e.ctrlKey || e.metaKey)) { 4739 if (e.key === 'b') { e.preventDefault(); applyMdFormat('bold'); } 4740 else if (e.key === 'i') { e.preventDefault(); applyMdFormat('italic'); } 4741 else if (e.key === 'k') { e.preventDefault(); applyMdFormat('link'); } 4742 } 4743 }); 4744 4745 // ââ In-document find (Ctrl+F) ââ 4746 let _findMatches = []; 4747 let _findIdx = -1; 4748 4749 function _openFindBar() { 4750 const bar = document.getElementById('doc-find-bar'); 4751 if (!bar) return; 4752 bar.style.display = 'flex'; 4753 // The highlight overlay is normally display:none (single-layer 4754 // rendering â textarea owns the visible text). Find marks live 4755 // inside that overlay, so we have to re-show it while find is 4756 // active. The body class lets a CSS rule un-hide it without 4757 // touching every per-language stylesheet path. 4758 document.body.classList.add('doc-find-active'); 4759 const inp = document.getElementById('doc-find-input'); 4760 if (inp) { inp.focus(); inp.select(); } 4761 } 4762 function _closeFindBar() { 4763 const bar = document.getElementById('doc-find-bar'); 4764 if (bar) bar.style.display = 'none'; 4765 document.body.classList.remove('doc-find-active'); 4766 _findMatches = []; 4767 _findIdx = -1; 4768 const cnt = document.getElementById('doc-find-count'); 4769 if (cnt) cnt.textContent = ''; 4770 const codeEl = document.getElementById('doc-editor-code'); 4771 if (codeEl) {
4772 delete codeEl.dataset.findQuery; 4773 delete codeEl.dataset.findCurrent; 4774 applyFindMarks(codeEl); 4775 } 4776 renderFindRects([], -1); 4777 ta.focus(); 4778 } 4779 function _doFind(dir, focusTextarea) { 4780 const inp = document.getElementById('doc-find-input'); 4781 const cnt = document.getElementById('doc-find-count'); 4782 if (!inp) return; 4783 const q = inp.value; 4784 const codeEl = document.getElementById('doc-editor-code'); 4785 if (!q) { 4786 _findMatches = []; _findIdx = -1; 4787 if (cnt) cnt.textContent = ''; 4788 if (codeEl) { delete codeEl.dataset.findQuery; delete codeEl.dataset.findCurrent; applyFindMarks(codeEl); } 4789 return; 4790 } 4791 const text = ta.value; 4792 const lq = q.toLowerCase(); 4793 const lt = text.toLowerCase(); 4794 _findMatches = []; 4795 let pos = 0; 4796 while (true) { 4797 const i = lt.indexOf(lq, pos); 4798 if (i < 0) break; 4799 _findMatches.push(i); 4800 pos = i + 1; 4801 } 4802 if (_findMatches.length === 0) { 4803 _findIdx = -1; 4804 if (cnt) cnt.textContent = '0 results'; 4805 if (codeEl) { codeEl.dataset.findQuery = q; delete codeEl.dataset.findCurrent; applyFindMarks(codeEl); } 4806 renderFindRects([], -1); 4807 return; 4808 } 4809 if (dir === 'next') { 4810 _findIdx = _findIdx < _findMatches.length - 1 ? _findIdx + 1 : 0; 4811 } else if (dir === 'prev') { 4812 _findIdx = _findIdx > 0 ? _findIdx - 1 : _findMatches.length - 1; 4813 } else { 4814 _findIdx = 0; 4815 } 4816 if (cnt) cnt.textContent = `${_findIdx + 1} / ${_findMatches.length}`; 4817 const matchPos = _findMatches[_findIdx]; 4818 // Highlight the match in the textarea without stealing focus from the input 4819 ta.setSelectionRange(matchPos, matchPos + q.length); 4820 const linesBefore = text.slice(0, matchPos).split('\n').length; 4821 const lineH = parseFloat(getComputedStyle(ta).lineHeight) || 18; 4822 ta.scrollTop = Math.max(0, (linesBefore - 3) * lineH); 4823 if (codeEl) { 4824 codeEl.dataset.findQuery = q; 4825 codeEl.dataset.findCurrent = String(_findIdx); 4826 applyFindMarks(codeEl); 4827 } 4828 // Dedicated overlay rects on top of the textarea â bulletproof 4829 // visibility across markdown / email / code modes. 4830 renderFindRects(_findMatches.map(s => [s, s + q.length]), _findIdx); 4831 if (focusTextarea) ta.focus(); 4832 } 4833 4834 document.getElementById('doc-find-close')?.addEventListener('click', _closeFindBar); 4835 document.getElementById('doc-find-next')?.addEventListener('click', () => _doFind('next', true)); 4836 document.getElementById('doc-find-prev')?.addEventListener('click', () => _doFind('prev', true)); 4837 document.getElementById('doc-find-input')?.addEventListener('input', () => _doFind('first', false)); 4838 document.getElementById('doc-find-input')?.addEventListener('keydown', (e) => { 4839 if (e.key === 'Escape') { e.preventDefault(); _closeFindBar(); } 4840 else if (e.key === 'Enter') { e.preventDefault(); _doFind(e.shiftKey ? 'prev' : 'next', false); } 4841 }); 4842 4843 // Intercept Ctrl+F on the editor pane 4844 pane.addEventListener('keydown', (e) => { 4845 if ((e.ctrlKey || e.metaKey) && e.key === 'f') { 4846 e.preventDefault(); 4847 e.stopPropagation(); 4848 _openFindBar(); 4849 } 4850 }); 4851 4852 // Delete (or Backspace) over the doc PANEL itself (not while typing in 4853 // a field) deletes the active document. Matches the email-reader Delete 4854 // behavior so the keyboard shortcut is consistent across surfaces. 4855 document.addEventListener('keydown', (e) => { 4856 if (e.key !== 'Delete' && e.key !== 'Backspace') return; 4857 if (!isPanelOpen()) return; 4858 const t = e.target; 4859 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; 4860 e.preventDefault(); 4861 deleteActiveDocument(); 4862 }); 4863 4864 // Drag-and-drop file attachment for email compose docs. The whole pane 4865 // is the drop target; visual highlight while a drag is hovering. Files 4866 // dropped get uploaded via the same compose-upload endpoint as the 4867 // file picker. 4868 let _dragDepth = 0; 4869 const _isEmailDrag = (e) => { 4870 const doc = docs.get(activeDocId); 4871 if (!doc || doc.language !== 'email') return false;
4872 const dt = e.dataTransfer; 4873 if (!dt) return false; 4874 // Files-only â don't trigger on text drags etc. 4875 return dt.types && Array.from(dt.types).includes('Files'); 4876 }; 4877 pane.addEventListener('dragenter', (e) => { 4878 if (!_isEmailDrag(e)) return; 4879 e.preventDefault(); 4880 _dragDepth++; 4881 pane.classList.add('email-dragover'); 4882 }); 4883 pane.addEventListener('dragover', (e) => { 4884 if (!_isEmailDrag(e)) return; 4885 e.preventDefault(); 4886 e.dataTransfer.dropEffect = 'copy'; 4887 }); 4888 pane.addEventListener('dragleave', (e) => { 4889 if (!_isEmailDrag(e)) return; 4890 _dragDepth = Math.max(0, _dragDepth - 1); 4891 if (_dragDepth === 0) pane.classList.remove('email-dragover'); 4892 }); 4893 pane.addEventListener('drop', async (e) => { 4894 if (!_isEmailDrag(e)) return; 4895 e.preventDefault(); 4896 _dragDepth = 0; 4897 pane.classList.remove('email-dragover'); 4898 const files = e.dataTransfer.files; 4899 if (files && files.length) await _uploadComposeFiles(files); 4900 }); 4901 4902 // Track selection for AI-assisted editing 4903 ta.addEventListener('mouseup', () => { 4904 setTimeout(updateSelectionState, 50); 4905 }); 4906 ta.addEventListener('keyup', (e) => { 4907 if (e.shiftKey) updateSelectionState(); 4908 }); 4909 // ESC clears any pinned selections â matches the badge's clear 4910 // button so users have a keyboard shortcut for the same action. 4911 ta.addEventListener('keydown', (e) => { 4912 if (e.key === 'Escape' && _selections.length > 0) { 4913 e.preventDefault(); 4914 e.stopPropagation(); 4915 clearSelection(); 4916 } 4917 }); 4918 } 4919 4920 renderTabs(); 4921 4922 // If no docs loaded, show empty state with helpful placeholder 4923 if (docs.size === 0 || !activeDocId) { 4924 showEmptyState(); 4925 } 4926 } 4927 4928 /** Apply markdown formatting to the textarea selection */ 4929 let _lastMdFormat = { action: null, t: 0 }; 4930 // Styled two-field link dialog (display text + URL). Resolves {url, text} 4931 // or null on cancel. Reuses the styled-prompt CSS. Text is optional â left 4932 // empty it falls back to the selected text, then the URL itself. 4933 function _promptLink(defaultText = '') { 4934 return new Promise(resolve => { 4935 const overlay = document.createElement('div'); 4936 overlay.id = 'doc-link-prompt-overlay'; 4937 overlay.className = 'modal'; 4938 overlay.innerHTML = 4939 '<div class="modal-content styled-confirm-box styled-prompt-box">' + 4940 '<div class="modal-header"><h4>Insert link</h4></div>' + 4941 '<div class="modal-body">' + 4942 '<input type="text" id="doc-link-text" class="styled-prompt-input" placeholder="Link text (optional)" maxlength="500" />' + 4943 '<input type="url" id="doc-link-url" class="styled-prompt-input" placeholder="https://example.com" maxlength="2048" style="margin-top:8px;" />' + 4944 '</div>' + 4945 '<div class="modal-footer">' + 4946 '<button id="doc-link-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button>' + 4947 '<button id="doc-link-ok" class="confirm-btn confirm-btn-primary">Insert</button>' + 4948 '</div>' + 4949 '</div>'; 4950 document.body.appendChild(overlay); 4951 const textEl = overlay.querySelector('#doc-link-text'); 4952 const urlEl = overlay.querySelector('#doc-link-url'); 4953 textEl.value = defaultText || ''; 4954 function done(result) { 4955 overlay.remove(); 4956 document.removeEventListener('keydown', onKey, true); 4957 resolve(result); 4958 } 4959 function submit() { 4960 const url = (urlEl.value || '').trim(); 4961 if (!url) { urlEl.focus(); return; } 4962 done({ url, text: (textEl.value || '').trim() }); 4963 } 4964 function onKey(e) { 4965 if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(null); } 4966 } 4967 overlay.querySelector('#doc-link-ok').addEventListener('click', submit); 4968 overlay.querySelector('#doc-link-cancel').addEventListener('click', () => done(null)); 4969 overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); 4970 urlEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); 4971 textEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault();
4971 urlEl.focus(); } }); 4972 document.addEventListener('keydown', onKey, true); 4973 // Focus the URL field when the text is prefilled; otherwise start at text. 4974 requestAnimationFrame(() => { (defaultText ? urlEl : textEl).focus(); }); 4975 }); 4976 } 4977 4978 // Email WYSIWYG link insertion. We snapshot the Range first (the dialog steals 4979 // focus and would otherwise collapse it) and insert via direct DOM ops, since 4980 // execCommand is unreliable once focus has moved to the modal. 4981 async function _wysiwygInsertLink(rich) { 4982 const selObj = window.getSelection(); 4983 let savedRange = null; 4984 if (selObj && selObj.rangeCount) { 4985 const r = selObj.getRangeAt(0); 4986 if (rich.contains(r.commonAncestorContainer)) savedRange = r.cloneRange(); 4987 } 4988 const selText = savedRange ? savedRange.toString() : ''; 4989 let res; 4990 try { res = await _promptLink(selText); } catch (_) { res = null; } 4991 if (!res) { rich.focus(); return; } 4992 let url = (res.url || '').trim(); 4993 if (!url) { rich.focus(); return; } 4994 if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//')) url = 'https://' + url; 4995 const linkText = (res.text || '').trim() || selText || url; 4996 4997 if (!savedRange) { 4998 savedRange = document.createRange(); 4999 savedRange.selectNodeContents(rich); 5000 savedRange.collapse(false); 5001 } 5002 const a = document.createElement('a'); 5003 a.href = url; 5004 if (selText && linkText === selText) { 5005 // Unchanged selection â wrap it to keep any inline formatting. 5006 a.appendChild(savedRange.extractContents()); 5007 } else { 5008 savedRange.deleteContents(); 5009 a.textContent = linkText; 5010 } 5011 savedRange.insertNode(a); 5012 // Place the caret right after the inserted link. 5013 const after = document.createRange(); 5014 after.setStartAfter(a); 5015 after.collapse(true); 5016 rich.focus(); 5017 const s = window.getSelection(); 5018 s.removeAllRanges(); 5019 s.addRange(after); 5020 _syncEmailRichbody(rich); 5021 } 5022 5023 function applyMdFormat(action) { 5024 // Guard against a duplicate/"ghost" click firing the same toggle twice in 5025 // quick succession â that would wrap then immediately unwrap, so the 5026 // markers appear for a split second and vanish. 5027 const _now = Date.now(); 5028 if (_lastMdFormat.action === action && _now - _lastMdFormat.t < 350) return; 5029 _lastMdFormat = { action, t: _now }; 5030 // Email WYSIWYG: format the live rich text via execCommand instead of 5031 // inserting markdown markers into the (hidden) source textarea. 5032 const _rich = _emailRichbodyActive(); 5033 if (_rich) { 5034 _rich.focus(); 5035 // Link needs an async styled URL prompt â handle it separately so we can 5036 // save/restore the selection (opening the modal collapses it otherwise). 5037 if (action === 'link') { _wysiwygInsertLink(_rich); return; } 5038 const _cmd = { bold: 'bold', italic: 'italic', strike: 'strikeThrough', 5039 ul: 'insertUnorderedList', ol: 'insertOrderedList', hr: 'insertHorizontalRule' }; 5040 try { 5041 if (_cmd[action]) document.execCommand(_cmd[action]); 5042 else if (action === 'h1' || action === 'h2' || action === 'h3') { 5043 // Toggle: if the block is already this heading, revert to a normal 5044 // paragraph; otherwise apply (or switch to) the heading. 5045 const cur = _currentBlockTag(_rich); 5046 document.execCommand('formatBlock', false, (cur === action) ? 'div' : action); 5047 } else if (action === 'code') { 5048 const cur = _currentBlockTag(_rich); 5049 document.execCommand('formatBlock', false, (cur === 'pre') ? 'div' : 'pre'); 5050 } 5051 // quote/check/codeblock have no clean execCommand â skipped in WYSIWYG v1. 5052 } catch (_) {} 5053 _syncEmailRichbody(_rich); 5054 if (_rich._syncActive) _rich._syncActive(); 5055 return; 5056 } 5057 const ta = document.getElementById('doc-editor-textarea'); 5058 if (!ta) return; 5059 const start = ta.selectionStart; 5060 const end = ta.selectionEnd; 5061 const val = ta.value; 5062 const sel = val.substring(start, end); 5063 const before = val.substring(0, start); 5064 const after = val.substring(end); 5065 5066 // Inline wrap toggles: bold, italic, strike, code 5067 const wrapMarks = { bold: '**', italic: '*', strike: '~~', code: '`' }; 5068 if (wrapMarks[action]) { 5069 const m = wrapMarks[action]; 5070 _applyWrapToggle(ta, before, sel, after, start, end, m, action); 5071 return; 5072 } 5073 5074 // Numbered list â special handling for incrementing numbers 5075 if (action === 'ol') { 5076 _applyOrderedList(ta, start, end); 5077 return; 5078 } 5079 5080 // Headings get their own toggle so applying the same level removes it and 5081 // a different level switches cleanly (rather than stacking # markers). 5082 if (action === 'h1' || action === 'h2' || action === 'h3') { 5083 _applyHeadingToggle(ta, start, { h1: '# ', h2: '## ', h3: '### ' }[action]); 5084 return; 5085 } 5086 5087 // Line prefix toggles: quote, lists, checkbox 5088 const prefixMap = { quote: '> ', ul: '- ', check: '- [ ] ' }; 5089 if (prefixMap[action]) { 5090 _applyLinePrefixToggle(ta, start, end, prefixMap[action]); 5091 return; 5092 } 5093 5094 // Non-toggle actions 5095 let insert = ''; 5096 let sS = start, sE = start; 5097 switch (action) { 5098 case 'link': 5099 if (sel) { 5100 insert = `[${sel}](url)`; 5101 sS = start + 1; sE = start + 1 + sel.length; 5102 } else { 5103 insert = '[text](url)'; 5104 sS = start + 1; sE = start + 5; 5105 } 5106 break; 5107 case 'codeblock': { 5108 // Toggle: find if current line/selection is inside a ``` block 5109 const linesBefore = val.substring(0, start).split('\n'); 5110 const linesAfter = val.substring(end).split('\n'); 5111 // Look backward for opening ``` 5112 let openIdx = -1; 5113 for (let i = linesBefore.length - 1; i >= 0; i--) { 5114 if (/^```/.test(linesBefore[i].trimEnd())) { openIdx = i; break; } 5115 } 5116 // Look forward for closing ``` 5117 let closeIdx = -1; 5118 for (let i = 0; i < linesAfter.length; i++) { 5119 if (/^```\s*$/.test(linesAfter[i].trimEnd())) { closeIdx = i; break; } 5120 } 5121 if (openIdx >= 0 && closeIdx >= 0) { 5122 // Unwrap: remove the opening and closing fence lines 5123 const openLineStart = linesBefore.slice(0, openIdx).join('\n').length + (openIdx > 0 ? 1 : 0); 5124 const openLineEnd = openLineStart + linesBefore[openIdx].length + 1; // +1 for \n 5125 const closeLineStart = end + linesAfter.slice(0, closeIdx).join('\n').length + (closeIdx > 0 ? 1 : 0); 5126 const closeLineEnd = closeLineStart + linesAfter[closeIdx].length + (closeIdx < linesAfter.length - 1 ? 1 : 0); 5127 // Remove closing first (so indices stay valid), then opening 5128 _replaceRange(ta, closeLineStart, closeLineEnd, ''); 5129 _replaceRange(ta, openLineStart, openLineEnd, ''); 5130 const inner = val.substring(openLineEnd, closeLineStart); 5131 ta.selectionStart = openLineStart; 5132 ta.selectionEnd = openLineStart + inner.length; 5133 return; 5134 } 5135 // Wrap in code block 5136 const nl = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; 5137 insert = nl + '```\n' + (sel || '') + '\n```\n'; 5138 sS = start + nl.length + 4; 5139 sE = sS + (sel ? sel.length : 0); 5140 break; 5141 } 5142 case 'hr': { 5143 const nl = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; 5144 insert = `${nl}---\n`; 5145 sE = sS = start + insert.length; 5146 break; 5147 } 5148 default: return; 5149 } 5150 _replaceRange(ta, start, end, insert); 5151 ta.selectionStart = sS; 5152 ta.selectionEnd = sE; 5153 } 5154 5155 /** Replace a range in the textarea using execCommand to preserve undo stack */ 5156 function _replaceRange(ta, from, to, text) { 5157 ta.focus(); 5158 ta.selectionStart = from; 5159 ta.selectionEnd = to; 5160 const before = ta.value; 5161 let ok = false;
5162 try { ok = document.execCommand('insertText', false, text); } catch (_) { ok = false; } 5163 // execCommand('insertText') keeps native undo working. It silently no-ops on 5164 // some mobile browsers though â so ONLY when it changed nothing do we splice 5165 // the value directly (using the pre-edit value + original range, so we never 5166 // double-insert). execCommand fires its own input event; the splice path 5167 // dispatches one manually. 5168 if (!ok && ta.value === before) { 5169 ta.value = before.slice(0, from) + text + before.slice(to); 5170 ta.selectionStart = ta.selectionEnd = from + text.length; 5171 ta.dispatchEvent(new Event('input', { bubbles: true })); 5172 } 5173 } 5174 5175 /** Toggle inline wrap markers (**, *, ~~, `) */ 5176 function _applyWrapToggle(ta, before, sel, after, start, end, mark, action) { 5177 const mLen = mark.length; 5178 5179 // Case 1: selection is wrapped inside â e.g. selected "**bold**" â unwrap to "bold" 5180 if (sel.startsWith(mark) && sel.endsWith(mark) && sel.length > mLen * 2) { 5181 const inner = sel.slice(mLen, -mLen); 5182 _replaceRange(ta, start, end, inner); 5183 ta.selectionStart = start; 5184 ta.selectionEnd = start + inner.length; 5185 return; 5186 } 5187 5188 // Case 2: markers are outside selection â e.g. **|bold|** â unwrap 5189 if (before.endsWith(mark) && after.startsWith(mark)) { 5190 _replaceRange(ta, start - mLen, end + mLen, sel); 5191 ta.selectionStart = start - mLen; 5192 ta.selectionEnd = end - mLen; 5193 return; 5194 } 5195 5196 // Case 3: wrap â add markers. With no selection, insert empty markers and 5197 // drop the cursor between them (don't inject the action name as text). 5198 const inner = sel; 5199 const wrapped = mark + inner + mark; 5200 _replaceRange(ta, start, end, wrapped); 5201 ta.selectionStart = start + mLen; 5202 ta.selectionEnd = start + mLen + inner.length; 5203 } 5204 5205 /** Toggle line prefix (headings, quotes, lists) */ 5206 // The block-level tag (h1/h2/h3/pre/p/â¦) containing the current selection in 5207 // a contenteditable root â used to decide whether a heading toggle should 5208 // apply or revert. 5209 function _currentBlockTag(root) { 5210 const sel = window.getSelection(); 5211 if (!sel || !sel.rangeCount) return ''; 5212 let node = sel.getRangeAt(0).startContainer; 5213 if (node.nodeType === 3) node = node.parentNode; 5214 while (node && node !== root) { 5215 const tag = node.tagName && node.tagName.toLowerCase(); 5216 if (tag && /^(h1|h2|h3|h4|h5|h6|p|div|pre|blockquote|li)$/.test(tag)) return tag; 5217 node = node.parentNode; 5218 } 5219 return ''; 5220 } 5221 5222 // Heading toggle for the markdown textarea: strips any existing leading 5223 // `#{1,6} `, then removes it (toggle off) if it was the same level, or applies 5224 // the new level otherwise. 5225 function _applyHeadingToggle(ta, caret, prefix) { 5226 const val = ta.value; 5227 const lineStart = val.lastIndexOf('\n', caret - 1) + 1; 5228 const nlIdx = val.indexOf('\n', caret); 5229 const lineEnd = nlIdx === -1 ? val.length : nlIdx; 5230 const line = val.substring(lineStart, lineEnd); 5231 const m = line.match(/^(#{1,6}) /); 5232 let newLine; 5233 if (m && m[1].length === prefix.trim().length) { 5234 newLine = line.slice(m[0].length); // same level â toggle off 5235 } else if (m) { 5236 newLine = prefix + line.slice(m[0].length); // different level â switch 5237 } else { 5238 newLine = prefix + line; // none â add 5239 } 5240 _replaceRange(ta, lineStart, lineEnd, newLine); 5241 const delta = newLine.length - line.length; 5242 const pos = Math.max(lineStart, caret + delta); 5243 ta.selectionStart = ta.selectionEnd = pos; 5244 ta.focus(); 5245 } 5246 5247 function _applyLinePrefixToggle(ta, start, end, prefix) { 5248 const val = ta.value; 5249 const sel = val.substring(start, end); 5250 const lineStart = val.lastIndexOf('\n', start - 1) + 1; 5251 5252 if (sel) { 5253 // Multi-line: toggle prefix on each line 5254 const lines = sel.split('\n'); 5255 const nonEmpty = lines.filter(l => l.trim()); 5256 const allPrefixed = nonEmpty.length > 0 && nonEmpty.every(l => l.startsWith(prefix)); 5257 const result = allPrefixed 5258 ? lines.map(l => l.startsWith(prefix) ? l.slice(prefix.length) : l).join('\n') 5259 : lines.map(l => l.trim() ? prefix + l : l).join('\n'); 5260 _replaceRange(ta, start, end, result); 5261 ta.selectionStart = start; 5262 ta.selectionEnd = start + result.length; 5263 } else { 5264 // No selection: toggle prefix on the current line 5265 const lineBefore = val.substring(lineStart, start); 5266 5267 if (lineBefore.startsWith(prefix)) { 5268 // Remove prefix 5269 _replaceRange(ta, lineStart, lineStart + prefix.length, ''); 5270 } else { 5271 // Add prefix at line start 5272 _replaceRange(ta, lineStart, lineStart, prefix); 5273 } 5274 } 5275 } 5276 5277 /** Toggle ordered list with incrementing numbers */ 5278 function _applyOrderedList(ta, start, end) { 5279 const val = ta.value; 5280 const sel = val.substring(start, end); 5281 const lineStart = val.lastIndexOf('\n', start - 1) + 1; 5282 5283 if (sel) { 5284 const lines = sel.split('\n'); 5285 const nonEmpty = lines.filter(l => l.trim()); 5286 const allNumbered = nonEmpty.length > 0 && nonEmpty.every(l => /^\d+\.\s/.test(l)); 5287 const result = allNumbered 5288 ? lines.map(l => l.replace(/^\d+\.\s/, '')).join('\n') 5289 : (() => { let n = 0; return lines.map(l => l.trim() ? `${++n}. ${l}` : l).join('\n'); })(); 5290 _replaceRange(ta, start, end, result); 5291 ta.selectionStart = start; 5292 ta.selectionEnd = start + result.length; 5293 } else { 5294 const lineBefore = val.substring(lineStart, start); 5295 if (/^\d+\.\s/.test(lineBefore)) { 5296 const prefixLen = lineBefore.match(/^\d+\.\s/)[0].length; 5297 _replaceRange(ta, lineStart, lineStart + prefixLen, ''); 5298 } else { 5299 // Find the previous numbered line to continue the sequence 5300 const prevText = val.substring(0, lineStart); 5301 const prevMatch = prevText.match(/(\d+)\.\s[^\n]*\n$/); 5302 const num = prevMatch ? parseInt(prevMatch[1]) + 1 : 1; 5303 _replaceRange(ta, lineStart, lineStart, `${num}. `); 5304 } 5305 } 5306 } 5307 5308 /** Wire up the markdown formatting toolbar */ 5309 // Grouped formatting dropdown (headings / code / lists). Menu is appended to 5310 // <body> so the draggable panel's transform can't clip its fixed position. 5311 let _mdDdOpenedAt = 0; 5312 function _showMdDropdown(toggleBtn) {
5313 const kind = toggleBtn.dataset.dd; 5314 const now = Date.now(); 5315 const existing = document.getElementById('doc-md-dd-menu'); 5316 // Mobile fires a duplicate/ghost click right after the real one. If it lands 5317 // on the same toggle it would re-toggle the menu shut the instant it opened. 5318 // Ignore a same-kind re-invocation within 400ms so the menu stays up. 5319 if (existing && existing.dataset.dd === kind && (now - _mdDdOpenedAt) < 400) return; 5320 const prevKind = existing && existing.dataset.dd; 5321 if (existing) existing.remove(); 5322 if (existing && prevKind === kind) return; // same toggle clicked â just close 5323 _mdDdOpenedAt = now; 5324 5325 const groups = { 5326 heading: [['h1', 'Heading 1', 'H1'], ['h2', 'Heading 2', 'H2'], ['h3', 'Heading 3', 'H3']], 5327 code: [['code', 'Inline code', '`'], ['codeblock', 'Code block', '```']], 5328 list: [['ul', 'Bullet list', 'â¢'], ['ol', 'Numbered list', '1.']], 5329 }; 5330 const items = groups[kind]; 5331 if (!items) return; 5332 5333 const rect = toggleBtn.getBoundingClientRect(); 5334 const menu = document.createElement('div'); 5335 menu.id = 'doc-md-dd-menu'; 5336 menu.dataset.dd = kind; 5337 menu.className = 'doc-overflow-menu open'; 5338 menu.style.position = 'fixed'; 5339 menu.style.top = (rect.bottom + 4) + 'px'; 5340 menu.style.left = rect.left + 'px'; 5341 menu.style.zIndex = '9999'; 5342 items.forEach(([md, label, ico]) => { 5343 const it = document.createElement('button'); 5344 it.className = 'doc-overflow-item'; 5345 const icoSpan = document.createElement('span'); 5346 icoSpan.className = 'md-dd-ico'; 5347 icoSpan.textContent = ico; 5348 const lbl = document.createElement('span'); 5349 lbl.textContent = label; 5350 it.append(icoSpan, lbl); 5351 // Don't let the menu item steal focus from the editor (preserve selection). 5352 it.addEventListener('mousedown', (ev) => ev.preventDefault()); 5353 it.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); applyMdFormat(md); }); 5354 menu.appendChild(it); 5355 }); 5356 document.body.appendChild(menu); 5357 5358 const close = (ev) => { 5359 if (ev && ev.type === 'keydown') { 5360 if (ev.key !== 'Escape') return; 5361 ev.preventDefault(); 5362 ev.stopPropagation(); 5363 ev.stopImmediatePropagation?.(); 5364 } 5365 if (ev && ev.type === 'click') { 5366 // Ignore the ghost/duplicate click mobile fires right after opening. 5367 if (Date.now() - _mdDdOpenedAt < 400) return; 5368 if (menu.contains(ev.target) || toggleBtn.contains(ev.target)) return; 5369 } 5370 menu.remove(); 5371 document.removeEventListener('click', close, true); 5372 document.removeEventListener('keydown', close, true); 5373 window.removeEventListener('scroll', close, true); 5374 window.removeEventListener('resize', close, true); 5375 }; 5376 setTimeout(() => { 5377 document.addEventListener('click', close, true); 5378 document.addEventListener('keydown', close, true); 5379 window.addEventListener('scroll', close, true); 5380 window.addEventListener('resize', close, true); 5381 }, 0); 5382 } 5383 5384 function initMdToolbar() { 5385 const toolbar = document.getElementById('doc-md-toolbar'); 5386 if (!toolbar) return; 5387 5388 const itemsWrap = document.getElementById('md-toolbar-items'); 5389 const overflowWrapper = document.getElementById('md-toolbar-overflow-wrapper'); 5390 const overflowToggle = document.getElementById('md-toolbar-overflow-toggle'); 5391 const overflowMenu = document.getElementById('md-toolbar-overflow-menu'); 5392 const undoBtn = document.getElementById('md-toolbar-undo'); 5393 5394 // Click handler for format buttons + the grouped dropdown toggles. The menu 5395 // is appended to <body> (not nested in the toolbar) so the draggable panel's 5396 // CSS transform doesn't reparent its fixed positioning or clip it. 5397 // Keep the editor's focus + selection when a format button / dropdown 5398 // toggle is pressed. Without this the button steals focus on press, which 5399 // collapses the textarea selection (so B/I/S apply to nothing) and, on 5400 // mobile, drops the keyboard â whose viewport resize then instantly closes 5401 // any dropdown that just opened. Preventing the default mousedown keeps the 5402 // textarea focused, so formatting hits the live selection and menus stay up. 5403 toolbar.addEventListener('mousedown', (e) => { 5404 if (e.target.closest('[data-md], .md-dd-toggle, .emoji-picker-btn')) e.preventDefault(); 5405 }); 5406 5407 toolbar.addEventListener('click', (e) => { 5408 const dd = e.target.closest('.md-dd-toggle'); 5409 if (dd) { e.preventDefault(); _showMdDropdown(dd); return; } 5410 const btn = e.target.closest('[data-md]'); 5411 if (!btn) return; 5412 e.preventDefault(); 5413 applyMdFormat(btn.dataset.md); 5414 }); 5415 5416 // Undo button 5417 if (undoBtn) { 5418 undoBtn.addEventListener('click', (e) => { 5419 e.preventDefault(); 5420 const ta = document.getElementById('doc-editor-textarea'); 5421 if (ta) { ta.focus(); document.execCommand('undo'); } 5422 }); 5423 } 5424 5425 // Overflow collapse logic 5426 let _mdMenuOpen = false;
5427 // Horizontal-scroll affordance: the toolbar scrolls its icons; edge arrows 5428 // appear when there's more off either side and smoothly scroll to that edge. 5429 const scrollLeftBtn = document.getElementById('md-scroll-left'); 5430 const scrollRightBtn = document.getElementById('md-scroll-right'); 5431 function updateScrollArrows() { 5432 if (!itemsWrap || !scrollLeftBtn || !scrollRightBtn) return; 5433 const maxScroll = itemsWrap.scrollWidth - itemsWrap.clientWidth; 5434 const overflowing = maxScroll > 2; 5435 scrollLeftBtn.style.display = (overflowing && itemsWrap.scrollLeft > 1) ? 'flex' : 'none'; 5436 scrollRightBtn.style.display = (overflowing && itemsWrap.scrollLeft < maxScroll - 1) ? 'flex' : 'none'; 5437 } 5438 scrollLeftBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: 0, behavior: 'smooth' })); 5439 scrollRightBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: itemsWrap.scrollWidth, behavior: 'smooth' })); 5440 itemsWrap?.addEventListener('scroll', updateScrollArrows, { passive: true }); 5441 if (window.ResizeObserver && itemsWrap) { 5442 new ResizeObserver(updateScrollArrows).observe(itemsWrap); 5443 } 5444 5445 function syncMdOverflow() { 5446 if (overflowWrapper) overflowWrapper.style.display = 'none'; 5447 updateScrollArrows(); 5448 } 5449 5450 function closeMdMenu() { 5451 _mdMenuOpen = false; 5452 if (overflowMenu) overflowMenu.classList.remove('open'); 5453 } 5454 5455 if (overflowToggle) { 5456 overflowToggle.addEventListener('click', (e) => { 5457 e.stopPropagation(); 5458 _mdMenuOpen = !_mdMenuOpen; 5459 if (_mdMenuOpen) { 5460 document.body.appendChild(overflowMenu); 5461 const rect = overflowToggle.getBoundingClientRect(); 5462 overflowMenu.style.position = 'fixed'; 5463 overflowMenu.style.top = (rect.bottom + 2) + 'px'; 5464 overflowMenu.style.right = (window.innerWidth - rect.right) + 'px'; 5465 overflowMenu.style.left = 'auto'; 5466 } else { 5467 overflowWrapper.appendChild(overflowMenu); 5468 } 5469 overflowMenu.classList.toggle('open', _mdMenuOpen); 5470 }); 5471 } 5472 document.addEventListener('click', () => { 5473 if (_mdMenuOpen) { closeMdMenu(); overflowWrapper.appendChild(overflowMenu); } 5474 }); 5475 5476 // Re-check overflow on resize 5477 let _mdResizeTimer; 5478 window.addEventListener('resize', () => { 5479 clearTimeout(_mdResizeTimer); 5480 _mdResizeTimer = setTimeout(syncMdOverflow, 100); 5481 }); 5482 5483 // Show toolbar if language is already markdown 5484 const lang = document.getElementById('doc-language-select')?.value; 5485 if (lang === 'markdown') toolbar.style.display = ''; 5486 5487 // Initial sync after layout 5488 requestAnimationFrame(syncMdOverflow); 5489 // Expose for external calls (e.g. after fullscreen toggle) 5490 toolbar._syncOverflow = syncMdOverflow; 5491 } 5492 5493 /** Collapse action buttons into overflow "..." menu (3 most-used visible) */ 5494 const _DOC_RECENTS_KEY = 'odysseus-doc-actions-recent'; 5495 const _DOC_MAX_VISIBLE = 2; 5496 5497 function _getDocRecent() { 5498 try { return JSON.parse(localStorage.getItem(_DOC_RECENTS_KEY) || '[]'); } catch { return []; } 5499 } 5500 function _trackDocAction(id) { 5501 let recent = _getDocRecent().filter(x => x !== id); 5502 recent.unshift(id); 5503 if (recent.length > 10) recent.length = 10; 5504 localStorage.setItem(_DOC_RECENTS_KEY, JSON.stringify(recent)); 5505 } 5506 5507 function initActionOverflow() { 5508 const actionsEl = document.getElementById('doc-editor-actions'); 5509 const wrapper = document.getElementById('doc-overflow-wrapper'); 5510 const toggle = document.getElementById('doc-overflow-toggle'); 5511 const menu = document.getElementById('doc-overflow-menu'); 5512 if (!actionsEl || !wrapper || !toggle || !menu) return; 5513 5514 const allBtns = Array.from(actionsEl.querySelectorAll('.doc-collapsible-btn')); 5515 let _menuOpen = false; 5516 5517 function syncOverflow() { 5518 allBtns.forEach(b => { b.classList.remove('doc-collapsed'); }); 5519 menu.innerHTML = ''; 5520 5521 // Filter to currently visible buttons 5522 const available = allBtns.filter(b => b.style.display !== 'none'); 5523 5524 // Sort by recent usage, defaults: copy, export, save 5525 const recent = _getDocRecent(); 5526 const defaults = ['doc-copy-btn', 'doc-export-btn', 'doc-save-btn']; 5527 const order = recent.length > 0 ? recent : defaults; 5528 5529 // Auto-pin: md preview when language is markdown 5530 const lang = document.getElementById('doc-language-select')?.value; 5531 const pinned = []; 5532 if (lang === 'markdown') { 5533 const mdBtn = available.find(b => b.id === 'doc-md-btn'); 5534 if (mdBtn) pinned.push(mdBtn); 5535 } 5536 5537 const sorted = [...available].sort((a, b) => { 5538 const ai = order.indexOf(a.id), bi = order.indexOf(b.id); 5539 if (ai >= 0 && bi >= 0) return ai - bi; 5540 if (ai >= 0) return -1; 5541 if (bi >= 0) return 1; 5542 return 0; 5543 }); 5544 5545 // Pinned + top N (deduplicated) â pinned count against the max 5546 const visible = [...pinned]; 5547 for (const btn of sorted) { 5548 if (visible.length >= _DOC_MAX_VISIBLE) break; 5549 if (!visible.includes(btn)) visible.push(btn); 5550 } 5551 // Ensure we never exceed MAX_VISIBLE 5552 while (visible.length > _DOC_MAX_VISIBLE) visible.pop(); 5553 const overflow = sorted.filter(b => !visible.includes(b)); 5554 5555 // Show visible, hide overflow 5556 overflow.forEach(b => b.classList.add('doc-collapsed')); 5557 5558 // Reorder DOM: visible buttons before wrapper 5559 for (const btn of visible) { 5560 actionsEl.insertBefore(btn, wrapper); 5561 } 5562 5563 if (overflow.length > 0) { 5564 wrapper.style.display = ''; 5565 overflow.forEach(btn => { 5566 const item = document.createElement('button'); 5567 item.className = 'doc-overflow-item'; 5568 item.innerHTML = btn.innerHTML + '<span>' + (btn.title || '') + '</span>'; 5569 item.addEventListener('click', (e) => { 5570 _trackDocAction(btn.id); 5571 // Export button has its own submenu 5572 if (btn.id === 'doc-export-btn') { 5573 e.stopPropagation(); 5574 const savedRect = item.getBoundingClientRect(); 5575 closeMenu(); 5576 setTimeout(() => showExportMenu(null, savedRect), 50); 5577 return; 5578 } 5579 closeMenu(); 5580 btn.click(); 5581 syncOverflow(); // re-sort with new recency 5582 }); 5583 menu.appendChild(item); 5584 }); 5585 } else { 5586 wrapper.style.display = 'none'; 5587 } 5588 } 5589 5590 function closeMenu() { 5591 _menuOpen = false;
5592 menu.classList.remove('open'); 5593 } 5594 5595 toggle.addEventListener('click', (e) => { 5596 e.stopPropagation(); 5597 _menuOpen = !_menuOpen; 5598 if (_menuOpen) { 5599 // Move to body to escape overflow:hidden on doc-editor-pane 5600 document.body.appendChild(menu); 5601 const rect = toggle.getBoundingClientRect(); 5602 menu.style.position = 'fixed'; 5603 menu.style.top = (rect.bottom + 2) + 'px'; 5604 menu.style.right = (window.innerWidth - rect.right) + 'px'; 5605 menu.style.left = 'auto'; 5606 } else { 5607 wrapper.appendChild(menu); 5608 } 5609 menu.classList.toggle('open', _menuOpen); 5610 }); 5611 document.addEventListener('click', () => { 5612 if (_menuOpen) { closeMenu(); wrapper.appendChild(menu); } 5613 }); 5614 5615 // Also track when visible buttons are clicked directly 5616 allBtns.forEach(btn => { 5617 btn.addEventListener('click', () => { 5618 _trackDocAction(btn.id); 5619 // Defer re-sort so the click handler fires first 5620 setTimeout(syncOverflow, 100); 5621 }); 5622 }); 5623 5624 requestAnimationFrame(syncOverflow); 5625 _syncOverflow = syncOverflow; 5626 } 5627 5628 /** Divider drag to resize the editor pane */ 5629 function initDividerDrag(divider, pane, isRight) { 5630 let dragging = false; 5631 divider.addEventListener('mousedown', (e) => { 5632 dragging = true; 5633 document.body.style.cursor = 'col-resize'; 5634 document.body.style.userSelect = 'none'; 5635 e.preventDefault(); 5636 }); 5637 document.addEventListener('mousemove', (e) => { 5638 if (!dragging) return; 5639 const width = isRight 5640 ? e.clientX 5641 : window.innerWidth - e.clientX; 5642 pane.style.width = Math.max(250, Math.min(width, window.innerWidth * 0.7)) + 'px'; 5643 pane.style.flex = 'none'; 5644 }); 5645 document.addEventListener('mouseup', () => { 5646 if (dragging) { 5647 dragging = false; 5648 document.body.style.cursor = ''; 5649 document.body.style.userSelect = ''; 5650 // Re-sync highlighting and line numbers after resize 5651 syncHighlighting(); 5652 const ta = document.getElementById('doc-editor-textarea'); 5653 if (ta) updateLineNumbers(ta.value); 5654 } 5655 }); 5656 } 5657 5658 /** Close the editor panel */ 5659 // When the doc panel is "tab/chevron down" minimized, it lives as a chip 5660 // in the bottom dock instead of a red toolbar indicator. We remember which 5661 // doc was active so the chip can restore it. 5662 let _minimizedDocId = null; 5663 5664 function _ensureDocChipRegistered() { 5665 if (Modals.isRegistered('doc-panel')) return; 5666 Modals.register('doc-panel', { 5667 // The â / drag-to-trash on the minimized chip is a real close â detach 5668 // the doc from the chat session so it doesn't reappear in that chat. 5669 closeFn: () => { 5670 // Content was already saved to the map when the panel was minimized, 5671 // so just detach (don't re-read the now-removed editor). 5672 const id = _minimizedDocId; 5673 _minimizedDocId = null; 5674 if (id) _detachDocFromSession(id); 5675 }, 5676 restoreFn: () => { 5677 const id = _minimizedDocId; 5678 _minimizedDocId = null; 5679 // openPanel builds the pane shell; switchToDoc re-renders the
5680 // saved doc content into it (including PDF render-pages, syntax 5681 // highlighting, etc.). Without switchToDoc, the pane is empty. 5682 openPanel(); 5683 if (id && docs.has(id)) { 5684 try { switchToDoc(id); } catch (e) { console.error('Restore doc failed:', e); } 5685 } 5686 }, 5687 }); 5688 } 5689 5690 export function closePanel(direction) { 5691 if (!isOpen) { 5692 if (direction !== 'down' && Modals.isRegistered('doc-panel')) { 5693 _minimizedDocId = null; 5694 _markDocVisibleState(_lastSessionId, 'closed'); 5695 Modals.unregister('doc-panel'); 5696 } 5697 return; 5698 } 5699 isOpen = false; 5700 // On touch, closing the doc should leave the keyboard DOWN. The tap blurs 5701 // the textarea (keyboard starts down), but a stray refocus during teardown 5702 // (the view behind regaining focus, etc.) was bouncing it back up. Blur any 5703 // focused field now and again after the close settles to keep it down. 5704 if (direction !== 'down' && (('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0)) { 5705 const _dropKb = () => { 5706 const ae = document.activeElement; 5707 if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) { try { ae.blur(); } catch (_) {} } 5708 }; 5709 _dropKb(); 5710 requestAnimationFrame(_dropKb); 5711 setTimeout(_dropKb, 80); 5712 } 5713 // Save current state 5714 saveCurrentToMap(); 5715 5716 // A "down" close means minimize, not close. Register the chip and flip 5717 // the dock state to minimized so a chip appears at the bottom. Any 5718 // other direction is a real close â make sure any leftover chip from a 5719 // prior minimize cycle is cleared too. 5720 if (direction === 'down') { 5721 _minimizedDocId = activeDocId; 5722 _markDocVisibleState(_lastSessionId, 'minimized'); 5723 _ensureDocChipRegistered(); 5724 Modals.minimize('doc-panel'); 5725 } else if (Modals.isRegistered('doc-panel')) { 5726 _minimizedDocId = null; 5727 _markDocVisibleState(_lastSessionId, 'closed'); 5728 Modals.unregister('doc-panel'); 5729 } else { 5730 _markDocVisibleState(_lastSessionId, 'closed'); 5731 } 5732 5733 const pane = document.getElementById('doc-editor-pane'); 5734 const divider = document.getElementById('doc-divider'); 5735 5736 const _finishClose = () => { 5737 // If the panel was reopened during the slide-out animation (close â 5738 // reopen fast, e.g. close a draft then immediately compose a new one), 5739 // bail â otherwise this stale close strips doc-view after the new open 5740 // re-added it, and the fresh pane drops into the desktop split layout 5741 // (renders as a narrow "sidebar" on mobile). 5742 if (isOpen) { if (pane) pane.remove(); if (divider) divider.remove(); return; } 5743 document.body.classList.remove('doc-view'); 5744 const container = document.getElementById('chat-container'); 5745 if (container) container.style.display = ''; 5746 if (pane) pane.remove(); 5747 if (divider) divider.remove(); 5748 activeDocId = null; 5749 const btn = document.getElementById('overflow-doc-btn'); 5750 if (btn) btn.classList.remove('active'); 5751 const docInd = document.getElementById('doc-indicator-btn'); 5752 if (docInd) docInd.classList.remove('active'); 5753 }; 5754 5755 if (pane) { 5756 // Determine slide direction 5757 let transform; 5758 if (direction === 'down') { 5759 // Full slide off-screen on mobile (sheet dismiss); small nudge on desktop. 5760 transform = window.innerWidth <= 768 ? 'translateY(100%)' : 'translateY(30px)'; 5761 } else { 5762 const fromLeft = pane.classList.contains('doc-left'); 5763 transform = fromLeft ? 'translateX(-40px)' : 'translateX(40px)'; 5764 } 5765 pane.style.transition = 'transform 0.15s ease-in, opacity 0.1s ease-in'; 5766 pane.style.transform = transform; 5767 pane.style.opacity = '0'; 5768 if (divider) { divider.style.transition = 'opacity 0.1s ease-in'; divider.style.opacity = '0'; } 5769 pane.addEventListener('transitionend', _finishClose, { once: true }); 5770 // Safety fallback 5771 setTimeout(_finishClose, 200); 5772 } else { 5773 _finishClose(); 5774 } 5775 } 5776 5777 /** Swap doc panel side (called when sidebar side changes) */ 5778 export function swapSide() { 5779 if (!isOpen) return; 5780 const pane = document.getElementById('doc-editor-pane'); 5781 const divider = document.getElementById('doc-divider'); 5782 const container = document.getElementById('chat-container'); 5783 if (!pane || !divider || !container) return; 5784 5785 const sidebar = document.getElementById('sidebar'); 5786 const isRight = sidebar && sidebar.classList.contains('right-side'); 5787 5788 if (isRight) { 5789 // Sidebar moved right â doc goes left (before chat) 5790 pane.classList.add('doc-left'); 5791 container.parentNode.insertBefore(pane, container); 5792 container.parentNode.insertBefore(divider, container); 5793 } else { 5794 // Sidebar moved left â doc goes right (after chat) 5795 pane.classList.remove('doc-left'); 5796 container.after(divider); 5797 divider.after(pane); 5798 } 5799 5800 // Re-init divider drag for the new side 5801 initDividerDrag(divider, pane, isRight); 5802 } 5803 5804 // ---- Document CRUD ---- 5805 5806 /** Create a new document for the current session */ 5807 // Create a new blank document, reusing the current/last session or 5808 // auto-creating one. Same flow as the tab-bar "+" â the single entry point 5809 // the sidebar Library "+" should use too. 5810 export async function newDocument() { 5811 let sessionId = docs.get(activeDocId)?.sessionId 5812 || _lastSessionId 5813 || (sessionModule && sessionModule.getCurrentSessionId()); 5814 if (!sessionId) {
5815 try { sessionId = await _autoCreateSession(); } 5816 catch (e) { console.error('Failed to auto-create session for document:', e); return; } 5817 } 5818 await createDocument(sessionId); 5819 } 5820 5821 export async function createDocument(sessionId) { 5822 if (_creatingDoc) return; 5823 _creatingDoc = true; 5824 // If the panel was in empty-state, the user may type into the editor 5825 // during the create round-trip â preserve that text into the new doc 5826 // instead of letting switchToDoc blank it. 5827 const wasEmpty = !activeDocId; 5828 try { 5829 const res = await fetch(`${API_BASE}/api/document`, { 5830 method: 'POST', 5831 headers: { 'Content-Type': 'application/json' }, 5832 body: JSON.stringify({ 5833 session_id: sessionId, 5834 title: '', 5835 content: '', 5836 language: 'markdown', 5837 }), 5838 }); 5839 const doc = await res.json(); 5840 addDocToTabs(doc, sessionId); 5841 if (!isOpen) openPanel(); 5842 // Re-enable editor if it was in empty state 5843 let textarea = document.getElementById('doc-editor-textarea'); 5844 if (textarea) { 5845 textarea.disabled = false; 5846 textarea.placeholder = 'Document content...'; 5847 } 5848 // Capture text typed during the round-trip (only when starting from the 5849 // empty editor â don't steal another doc's content). 5850 const typed = (wasEmpty && textarea && textarea.value.trim()) ? textarea.value : ''; 5851 switchToDoc(doc.id); 5852 if (typed) { 5853 textarea = document.getElementById('doc-editor-textarea'); 5854 if (textarea) textarea.value = typed; 5855 const d = docs.get(doc.id); 5856 if (d) d.content = typed; 5857 syncHighlighting(); 5858 clearTimeout(_autoSaveDebounce); 5859 _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800); 5860 } 5861 textarea = document.getElementById('doc-editor-textarea'); 5862 if (textarea) textarea.focus(); 5863 } catch (e) { 5864 console.error('Failed to create document:', e); 5865 if (uiModule) uiModule.showError('Failed to create document'); 5866 } finally { 5867 _creatingDoc = false; 5868 } 5869 } 5870 5871 /** Load an existing document into a tab */ 5872 /** Inject a freshly-created doc dict (from a POST response) directly into 5873 * the tabs without re-fetching it via GET. Fixes a race where GET 5874 * /api/document/{id} can 404 right after a successful POST â we already 5875 * have the full doc payload from the create response, no need to round-trip. 5876 */ 5877 export function injectFreshDoc(doc) { 5878 if (!doc || !doc.id) return; 5879 const sessionId = doc.session_id || _lastSessionId || null; 5880 addDocToTabs(doc, sessionId); 5881 // Use _ensureDocPaneMounted (not `if (!isOpen) openPanel()`): when a draft 5882 // is composed from the email modal, `isOpen` can be stale-true while the 5883 // actual pane was torn down â a bare openPanel() early-returns and the doc 5884 // mounts into a wrong/half-built pane (rendered as a narrow sidebar on 5885 // mobile instead of its own full-screen window). This remounts it cleanly. 5886 _ensureDocPaneMounted(); 5887 // Defer to next frame so the panel DOM exists before switchToDoc populates 5888 requestAnimationFrame(() => requestAnimationFrame(() => { 5889 switchToDoc(doc.id); 5890 })); 5891 } 5892 5893 export async function replaceEmailReplyBody(docId, replyText) { 5894 const doc = docs.get(docId); 5895 if (!doc) return; 5896 const fields = _parseEmailHeader(doc.content || ''); 5897 const lines = String(fields.body || '').split('\n'); 5898 const quoteIdx = lines.findIndex(line => 5899 /^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim()) 5900 || /^On .+ wrote:\s*$/i.test(line.trim()) 5901 ); 5902 const quote = quoteIdx >= 0 ? lines.slice(quoteIdx).join('\n') : ''; 5903 const ownText = _emailReplyOwnText(fields.body || ''); 5904 if (ownText && !/^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText)) { 5905 if (uiModule) uiModule.showToast('AI reply ready, but draft was edited'); 5906 return; 5907 } 5908 const body = String(replyText || '').trim() + (quote ? `\n\n${quote}` : ''); 5909 doc.content = _buildEmailContent( 5910 fields.to, 5911 fields.subject, 5912 fields.inReplyTo, 5913 fields.references, 5914 body, 5915 fields.sourceUid, 5916 fields.sourceFolder, 5917 fields.cc, 5918 fields.bcc, 5919 ); 5920 if (activeDocId === docId) { 5921 const textarea = document.getElementById('doc-editor-textarea'); 5922 if (textarea) await _streamEmailBodyText(textarea, body); 5923 } 5924 clearTimeout(_autoSaveDebounce); 5925 _autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800); 5926 } 5927 5928 // Force the panel into a genuinely-open state. `isOpen` can be true while the 5929 // pane was torn down by another full-screen view (e.g. opening a doc from the
5930 // email modal): in that case openPanel() early-returns and nothing mounts, so 5931 // the doc silently never appears. Reset the stale flag and re-open for real. 5932 function _ensureDocPaneMounted() { 5933 if (!isOpen || !document.getElementById('doc-editor-pane')) { 5934 isOpen = false; 5935 openPanel(); 5936 } 5937 } 5938 5939 export async function loadDocument(docId) { 5940 // If already in tabs, just switch 5941 if (docs.has(docId)) { 5942 _ensureDocPaneMounted(); 5943 switchToDoc(docId); 5944 return; 5945 } 5946 try { 5947 const res = await fetch(`${API_BASE}/api/document/${docId}`); 5948 if (!res.ok) throw new Error(res.status === 404 ? 'Not found' : `HTTP ${res.status}`); 5949 const doc = await res.json(); 5950 addDocToTabs(doc, doc.session_id); 5951 _ensureDocPaneMounted(); 5952 switchToDoc(doc.id); 5953 } catch (e) { 5954 console.error('Failed to load document:', e); 5955 if (uiModule) { 5956 const msg = e.message === 'Not found' 5957 ? 'Document not found â try opening it from the Library.' 5958 : 'Could not open document.'; 5959 uiModule.showError(msg); 5960 } 5961 } 5962 } 5963 5964 // Deep-link: #document-<id> opens that document on load / URL-bar nav. 5965 // Clicks on in-chat document anchors are handled separately (they call 5966 // preventDefault, so they don't change the hash); this covers refresh 5967 // and pasted/typed document URLs, which previously did nothing. 5968 function _maybeOpenDocFromHash() { 5969 const m = (window.location.hash || '').match(/^#document-(.+)$/); 5970 if (m) loadDocument(m[1]); 5971 } 5972 5973 /** Open panel and ensure a document exists, creating a session if needed */ 5974 export async function ensureDocPanel() { 5975 let sessionId = _lastSessionId 5976 || (sessionModule && sessionModule.getCurrentSessionId()); 5977 if (!sessionId) { 5978 try { 5979 sessionId = await _autoCreateSession(); 5980 } catch (e) { 5981 console.error('Failed to auto-create session for document:', e); 5982 openPanel(); 5983 return; 5984 } 5985 } 5986 await loadSessionDocs(sessionId); 5987 } 5988 5989 /** Create a session and sync it with the sessions module */ 5990 async function _autoCreateSession() { 5991 // Materialize pending chat first if one exists 5992 if (sessionModule && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) { 5993 await sessionModule.materializePendingSession(); 5994 const id = sessionModule.getCurrentSessionId(); 5995 if (id) { _lastSessionId = id; return id; } 5996 } 5997 // Preserve the current model when creating a doc session 5998 const curModel = sessionModule?.getCurrentModel ? sessionModule.getCurrentModel() : null; 5999 const sessions = sessionModule ? sessionModule.getSessions() : []; 6000 const match = curModel && sessions.find(s => s.model === curModel && s.endpoint_url); 6001 const fd = new FormData(); 6002 fd.append('name', `Notes ${new Date().toLocaleTimeString()}`); 6003 fd.append('skip_validation', 'true'); 6004 if (match) { 6005 fd.append('endpoint_url', match.endpoint_url); 6006 fd.append('model', match.model); 6007 if (match.endpoint_id) fd.append('endpoint_id', match.endpoint_id); 6008 } 6009 const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd }); 6010 if (!res.ok) throw new Error('Session create failed'); 6011 const payload = await res.json(); 6012 const sessionId = payload.id; 6013 _lastSessionId = sessionId; 6014 // Tell sessions module so chat uses the same session 6015 if (sessionModule && sessionModule.setCurrentSessionId) { 6016 sessionModule.setCurrentSessionId(sessionId); 6017 } 6018 if (sessionModule && sessionModule.loadSessions) sessionModule.loadSessions(); 6019 return sessionId; 6020 } 6021 6022 /** Load all documents for a session into tabs */ 6023 export async function loadSessionDocs(sessionId, opts = {}) { 6024 _lastSessionId = sessionId; 6025 const restoreMode = !!opts.restoreMode; 6026 const shouldRestoreOpen = localStorage.getItem(_docOpenKey(sessionId)) === '1'; 6027 const shouldRestoreMinimized = localStorage.getItem(_docMinimizedKey(sessionId)) === '1'; 6028 // Clear docs from other sessions so tabs are per-session, 6029 // but keep session-less docs (e.g. email compose) â they're independent
6030 for (const [id, doc] of [...docs]) { 6031 if (doc.sessionId && doc.sessionId !== sessionId) docs.delete(id); 6032 } 6033 activeDocId = null; 6034 6035 // Show loading state while fetching 6036 if (isOpen) _showLoadingOverlay(); 6037 6038 try { 6039 const res = await fetch(`${API_BASE}/api/documents/${sessionId}`); 6040 const allDocs = await res.json(); 6041 _hideLoadingOverlay(); 6042 // Only load active docs 6043 const activeDocs = allDocs.filter(d => d.is_active); 6044 if (activeDocs.length === 0) { 6045 // No docs yet â show empty editor, doc will be created when user types 6046 if (!restoreMode || shouldRestoreOpen) { 6047 if (!isOpen) openPanel(); 6048 showEmptyState(); 6049 renderTabs(); 6050 } 6051 return; 6052 } 6053 for (const doc of activeDocs) { 6054 if (!docs.has(doc.id)) { 6055 addDocToTabs(doc, sessionId); 6056 } 6057 } 6058 _syncDocIndicator(); 6059 // Switch to the most recently active one (or first) 6060 const target = activeDocs[0]; 6061 if (restoreMode && shouldRestoreMinimized && !shouldRestoreOpen) { 6062 activeDocId = null; 6063 _minimizedDocId = target.id; 6064 _markDocVisibleState(sessionId, 'minimized'); 6065 _ensureDocChipRegistered(); 6066 Modals.minimize('doc-panel'); 6067 return; 6068 } 6069 // Removed: the old "if restoreMode && !shouldRestoreOpen â stay 6070 // closed" branch. Users expect that entering a chat with an 6071 // attached document opens the panel automatically, not just shows 6072 // an indicator. The minimised branch above still respects an 6073 // explicit user choice to dock the panel; everything else falls 6074 // through to the "open panel" path below. 6075 if (false) { 6076 activeDocId = null; 6077 _minimizedDocId = null; 6078 if (Modals.isRegistered('doc-panel')) Modals.unregister('doc-panel'); 6079 return; 6080 } 6081 // Always open when there are docs â the minimised branch above 6082 // already returned for users who explicitly docked the panel. 6083 // The previous `if (!restoreMode || shouldRestoreOpen)` gate left 6084 // the panel closed on first entry to a chat with docs, which 6085 // hides the doc unless the user manually opens the panel. 6086 _markDocVisibleState(sessionId, 'open'); 6087 if (!isOpen) openPanel(); 6088 switchToDoc(target.id); 6089 } catch (e) { 6090 _hideLoadingOverlay(); 6091 console.error('Failed to load session documents:', e); 6092 // Open empty panel on error too 6093 if (!isOpen) openPanel(); 6094 showEmptyState(); 6095 } 6096 } 6097 6098 /** Add a document to the tabs map */ 6099 function addDocToTabs(doc, sessionId) { 6100 const existing = docs.get(doc.id); 6101 docs.set(doc.id, { 6102 id: doc.id, 6103 title: doc.title || '', 6104 language: doc.language || '', 6105 content: doc.current_content || '', 6106 version: doc.version_count || 1, 6107 sessionId: sessionId || doc.session_id, 6108 userSetLanguage: !!doc.language, 6109 _composeAtts: existing?._composeAtts, 6110 // Provenance for the "Send signed reply" flow 6111 sourceEmailUid: doc.source_email_uid || null, 6112 sourceEmailFolder: doc.source_email_folder || null, 6113 sourceEmailAccountId: doc.source_email_account_id || null, 6114 sourceEmailMessageId: doc.source_email_message_id || null, 6115 }); 6116 } 6117 6118 /** Populate the editor with document data (used internally) */ 6119 function populateEditor(doc) { 6120 const titleInput = document.getElementById('doc-title-input'); 6121 const textarea = document.getElementById('doc-editor-textarea'); 6122 const langSelect = document.getElementById('doc-language-select'); 6123 const badge = document.getElementById('doc-version-badge'); 6124 6125 if (titleInput) titleInput.value = doc.title || ''; 6126 if (textarea) textarea.value = doc.current_content || doc.content || ''; 6127 if (langSelect) langSelect.value = doc.language || 'markdown'; 6128 if (badge) { const _v = doc.version_count || doc.version || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; } 6129 { const _v = doc.version_count || doc.version || 1; const _dbtn = document.getElementById('doc-diff-toggle-btn'); if (_dbtn) _dbtn.style.display = _v > 1 ? '' : 'none'; }
6130 syncHighlighting(); 6131 } 6132 6133 /** Post-process hljs markdown output: colorize [brackets] and heading # markers */ 6134 function _postProcessMarkdown(codeEl) { 6135 const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT); 6136 const textNodes = []; 6137 while (walker.nextNode()) textNodes.push(walker.currentNode); 6138 for (const node of textNodes) { 6139 const text = node.textContent; 6140 // Skip nodes already inside hljs spans (like [link text] which is .hljs-string) 6141 if (node.parentElement !== codeEl && node.parentElement.className && 6142 /hljs-(string|link|code|section)/.test(node.parentElement.className)) continue; 6143 // Match standalone [bracketed text] not followed by (url) 6144 if (/\[[^\]]+\](?!\()/.test(text)) { 6145 const frag = document.createDocumentFragment(); 6146 let last = 0; 6147 const re = /\[([^\]]+)\](?!\()/g; 6148 let m; 6149 while ((m = re.exec(text)) !== null) { 6150 if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index))); 6151 const span = document.createElement('span'); 6152 span.className = 'md-bracket'; 6153 span.textContent = m[0]; 6154 frag.appendChild(span); 6155 last = re.lastIndex; 6156 } 6157 if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); 6158 if (last > 0) node.parentNode.replaceChild(frag, node); 6159 } 6160 } 6161 // Colorize heading # markers inside .hljs-section spans 6162 codeEl.querySelectorAll('.hljs-section').forEach(span => { 6163 const text = span.textContent; 6164 const hashMatch = text.match(/^(#{1,6})\s/); 6165 if (hashMatch) { 6166 const marker = document.createElement('span'); 6167 marker.className = 'md-heading-marker'; 6168 marker.textContent = hashMatch[1] + ' '; 6169 span.textContent = text.slice(hashMatch[0].length); 6170 span.prepend(marker); 6171 } 6172 }); 6173 } 6174 6175 // Find-result rectangles drawn ON TOP of the textarea â bypasses 6176 // the syntax-highlight overlay entirely so visibility works in 6177 // markdown, email, and any other mode regardless of single-layer- 6178 // rendering quirks. Same mirror-measurement approach as pinned 6179 // selections so wrap matches the textarea exactly. 6180 // 6181 // `matches` is an array of [start, end] offsets; `currentIdx` is 6182 // the focused one (gets brighter accent). Pass empty matches to 6183 // clear all rects. 6184 function renderFindRects(matches, currentIdx) { 6185 const wrap = document.getElementById('doc-editor-wrap'); 6186 if (!wrap) return; 6187 wrap.querySelectorAll('.doc-find-rect').forEach(el => el.remove()); 6188 if (!matches || matches.length === 0) return; 6189 const textarea = document.getElementById('doc-editor-textarea'); 6190 if (!textarea) return; 6191 const text = textarea.value; 6192 const style = getComputedStyle(textarea); 6193 const paddingTop = parseFloat(style.paddingTop) || 10; 6194 const paddingLeft = parseFloat(style.paddingLeft) || 48; 6195 const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45); 6196 6197 let mirror = document.getElementById('doc-find-rect-mirror'); 6198 if (!mirror) { 6199 mirror = document.createElement('div'); 6200 mirror.id = 'doc-find-rect-mirror'; 6201 mirror.style.cssText = 'position:absolute;top:0;left:0;right:0;visibility:hidden;pointer-events:none;' + 6202 'white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;overflow:hidden;box-sizing:border-box;'; 6203 wrap.appendChild(mirror); 6204 } 6205 mirror.style.font = style.font; 6206 mirror.style.padding = style.padding; 6207 mirror.style.borderWidth = style.borderWidth; 6208 mirror.style.borderStyle = 'solid'; 6209 mirror.style.borderColor = 'transparent'; 6210 mirror.style.width = textarea.clientWidth + 'px'; 6211 mirror.style.tabSize = style.tabSize; 6212 mirror.style.letterSpacing = style.letterSpacing; 6213 mirror.style.wordSpacing = style.wordSpacing; 6214 mirror.style.textIndent = style.textIndent; 6215 6216 const scrollTop = textarea.scrollTop; 6217 for (let i = 0; i < matches.length; i++) { 6218 const [s, e] = matches[i]; 6219 // Line-band style: highlight the FULL visual row containing the 6220 // match. Cheap, always-visible, doesn't need character-precise 6221 // mirror measurement that varies across email/markdown/code modes. 6222 mirror.textContent = text.substring(0, s); 6223 const startTop = mirror.scrollHeight - paddingTop; 6224 // Find the wrap-row's end by measuring with one extra char beyond 6225 // the match end and stepping back to the last whitespace boundary. 6226 mirror.textContent = text.substring(0, e); 6227 const endHeight = mirror.scrollHeight - paddingTop; 6228 mirror.textContent = ''; 6229 6230 const top = paddingTop + startTop - scrollTop; 6231 const height = Math.max(endHeight - startTop, lineHeight); 6232 const rect = document.createElement('div'); 6233 rect.className = 'doc-find-rect' + (i === currentIdx ? ' current' : ''); 6234 rect.style.cssText = 6235 `position:absolute;
6235left:${paddingLeft}px;right:8px;` + 6236 `top:${top}px;height:${height}px;` + 6237 `pointer-events:none;z-index:6;border-radius:2px;`; 6238 wrap.appendChild(rect); 6239 } 6240 } 6241 6242 /** Wrap find-matches in the syntax-highlighted overlay with <mark> spans. 6243 * Walks text nodes so existing hljs spans are preserved. Matches that cross 6244 * syntax tokens are skipped (rare for user searches). */ 6245 function applyFindMarks(codeEl) { 6246 if (!codeEl) return; 6247 // Remove prior find marks (unwrap) 6248 codeEl.querySelectorAll('mark.doc-find-mark').forEach(m => { 6249 const parent = m.parentNode; 6250 while (m.firstChild) parent.insertBefore(m.firstChild, m); 6251 parent.removeChild(m); 6252 parent.normalize(); 6253 }); 6254 const q = codeEl.dataset.findQuery || ''; 6255 if (!q) return; 6256 const currentIdx = parseInt(codeEl.dataset.findCurrent || '-1', 10); 6257 const lq = q.toLowerCase(); 6258 let occurrence = 0; 6259 const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT, null); 6260 const nodes = []; 6261 let n; 6262 while ((n = walker.nextNode())) nodes.push(n); 6263 for (const node of nodes) { 6264 const val = node.nodeValue || ''; 6265 const lv = val.toLowerCase(); 6266 if (!lv.includes(lq)) continue; 6267 const frag = document.createDocumentFragment(); 6268 let i = 0; 6269 while (i < val.length) { 6270 const hit = lv.indexOf(lq, i); 6271 if (hit < 0) { frag.appendChild(document.createTextNode(val.slice(i))); break; } 6272 if (hit > i) frag.appendChild(document.createTextNode(val.slice(i, hit))); 6273 const mark = document.createElement('mark'); 6274 mark.className = 'doc-find-mark' + (occurrence === currentIdx ? ' current' : ''); 6275 mark.textContent = val.slice(hit, hit + q.length); 6276 frag.appendChild(mark); 6277 occurrence++; 6278 i = hit + q.length; 6279 } 6280 node.parentNode.replaceChild(frag, node); 6281 } 6282 } 6283 6284 /** Sync highlighted overlay with textarea content */ 6285 function syncHighlighting() { 6286 const textarea = document.getElementById('doc-editor-textarea'); 6287 const codeEl = document.getElementById('doc-editor-code'); 6288 const pre = document.getElementById('doc-editor-highlight'); 6289 if (!textarea || !codeEl) return; 6290 6291 // Don't overwrite inline diff markers 6292 if (codeEl.dataset.hasDiff) return; 6293 6294 const text = textarea.value; 6295 // Trailing newline prevents scroll mismatch on last line 6296 codeEl.textContent = text + '\n'; 6297 6298 const lang = document.getElementById('doc-language-select')?.value; 6299 // hljs has no 'svg' grammar â highlight it as xml (the dropdown value stays 6300 // 'svg' so the preview/run routing still treats it as renderable markup). 6301 const _hlLang = lang === 'svg' ? 'xml' : lang; 6302 codeEl.className = _hlLang ? `language-${_hlLang}` : ''; 6303 if (window.hljs && _hlLang) { 6304 codeEl.removeAttribute('data-highlighted'); 6305 window.hljs.highlightElement(codeEl); 6306 } 6307 // Markdown post-processing: colorize standalone [brackets] and heading markers 6308 if (lang === 'markdown') { 6309 _postProcessMarkdown(codeEl); 6310 } 6311 6312 // Reapply find highlights after hljs rewrote the DOM 6313 if (codeEl.dataset.findQuery) applyFindMarks(codeEl); 6314 6315 // Keep scroll in sync 6316 if (pre) { 6317 codeEl.style.minHeight = textarea.scrollHeight + 'px'; 6318 pre.scrollTop = textarea.scrollTop; 6319 pre.scrollLeft = textarea.scrollLeft; 6320 } 6321 6322 // Update line numbers 6323 updateLineNumbers(text); 6324 } 6325 6326 /** Update the line number gutter */ 6327 let _lineNumberResizeObserver = null; 6328 let _lineNumberObservedTextarea = null; 6329 let _lineNumberResizeRaf = null; 6330 6331 function _lineNumberContentEl(gutter) { 6332 let inner = gutter.querySelector('.doc-line-number-content'); 6333 if (!inner) { 6334 inner = document.createElement('div'); 6335 inner.className = 'doc-line-number-content'; 6336 gutter.textContent = ''; 6337 gutter.appendChild(inner); 6338 } 6339 return inner; 6340 } 6341 6342 function _lineNumberStyleSignature(style) { 6343 return [ 6344 style.fontFamily, 6345 style.fontSize, 6346 style.fontWeight, 6347 style.fontStyle, 6348 style.lineHeight, 6349 style.letterSpacing, 6350 style.tabSize, 6351 style.fontFeatureSettings, 6352 style.fontVariantLigatures, 6353 style.fontKerning, 6354 ].join('|'); 6355 } 6356 6357 function _textareaTextWidth(textarea, style) { 6358 const paddingLeft = parseFloat(style.paddingLeft) || 0; 6359 const paddingRight = parseFloat(style.paddingRight) || 0; 6360 return Math.max(0, textarea.clientWidth - paddingLeft - paddingRight); 6361 } 6362 6363 function _lineHeightPx(style) { 6364 const parsed = parseFloat(style.lineHeight); 6365 if (Number.isFinite(parsed) && parsed > 0) return parsed; 6366 const fontSize = parseFloat(style.fontSize) || 11; 6367 return fontSize * 1.45; 6368 } 6369 6370 function _lineNumberMeasureEl(textarea) { 6371 const wrap = document.getElementById('doc-editor-wrap') || textarea.parentElement || document.body; 6372 let probe = wrap.querySelector('.doc-line-number-measure'); 6373 if (!probe) { 6374 probe = document.createElement('textarea'); 6375 probe.className = 'doc-line-number-measure'; 6376 probe.setAttribute('aria-hidden', 'true'); 6377 probe.tabIndex = -1; 6378 probe.readOnly = true; 6379 probe.wrap = 'soft'; 6380 wrap.appendChild(probe); 6381 } 6382 return probe; 6383 } 6384 6385 function _syncLineNumberMeasureStyle(probe, style, textWidth) { 6386 probe.style.width = textWidth + 'px'; 6387 probe.style.fontFamily = style.fontFamily; 6388 probe.style.fontSize = style.fontSize; 6389 probe.style.fontWeight = style.fontWeight; 6390 probe.style.fontStyle = style.fontStyle; 6391 probe.style.lineHeight = style.lineHeight; 6392 probe.style.letterSpacing = style.letterSpacing; 6393 probe.style.tabSize = style.tabSize; 6394 probe.style.fontFeatureSettings = style.fontFeatureSettings; 6395 probe.style.fontVariantLigatures = style.fontVariantLigatures; 6396 probe.style.fontKerning = style.fontKerning; 6397 probe.style.textRendering = style.textRendering; 6398 probe.style.whiteSpace = style.whiteSpace; 6399 probe.style.wordWrap = style.wordWrap; 6400 probe.style.overflowWrap = style.overflowWrap; 6401 } 6402 6403 function _measureLineNumberHeights(textarea, lines, textWidth, style) { 6404 const probe = _lineNumberMeasureEl(textarea); 6405 _syncLineNumberMeasureStyle(probe, style, textWidth); 6406 const lineHeight = _lineHeightPx(style); 6407 return lines.map(line => { 6408 probe.value = line || ' '; 6409 const visualRows = Math.max(1, Math.round(probe.scrollHeight / lineHeight)); 6410 return visualRows * lineHeight; 6411 }); 6412 } 6413 6414 function _renderLineNumberRows(inner, heights) { 6415 const frag = document.createDocumentFragment(); 6416 for (let i = 0; i < heights.length; i++) {
6417 const row = document.createElement('div'); 6418 row.className = 'doc-line-number-row'; 6419 row.style.height = `${heights[i]}px`; 6420 6421 const label = document.createElement('span'); 6422 label.className = 'doc-line-number-label'; 6423 label.textContent = String(i + 1); 6424 row.appendChild(label); 6425 frag.appendChild(row); 6426 } 6427 inner.textContent = ''; 6428 inner.appendChild(frag); 6429 } 6430 6431 function _scheduleLineNumberRerender() { 6432 if (_lineNumberResizeRaf) return; 6433 const run = () => { 6434 _lineNumberResizeRaf = null; 6435 const textarea = document.getElementById('doc-editor-textarea'); 6436 if (textarea) updateLineNumbers(textarea.value, true); 6437 }; 6438 if (typeof requestAnimationFrame === 'function') { 6439 _lineNumberResizeRaf = requestAnimationFrame(run); 6440 } else { 6441 run(); 6442 } 6443 } 6444 6445 function _ensureLineNumberResizeObserver(textarea) { 6446 if (typeof ResizeObserver === 'undefined') return; 6447 if (!_lineNumberResizeObserver) { 6448 _lineNumberResizeObserver = new ResizeObserver(_scheduleLineNumberRerender); 6449 } 6450 if (_lineNumberObservedTextarea === textarea) return; 6451 if (_lineNumberObservedTextarea) { 6452 _lineNumberResizeObserver.unobserve(_lineNumberObservedTextarea); 6453 } 6454 _lineNumberObservedTextarea = textarea; 6455 _lineNumberResizeObserver.observe(textarea); 6456 } 6457 6458 if (typeof window !== 'undefined') { 6459 window.addEventListener('resize', _scheduleLineNumberRerender); 6460 } 6461 6462 function updateLineNumbers(text, force = false) { 6463 const textarea = document.getElementById('doc-editor-textarea'); 6464 const gutter = document.getElementById('doc-line-numbers'); 6465 if (!textarea || !gutter) return; 6466 6467 const value = text || ''; 6468 const lines = value.split('\n'); 6469 const inner = _lineNumberContentEl(gutter); 6470 const style = getComputedStyle(textarea); 6471 const textWidth = _textareaTextWidth(textarea, style); 6472 const styleSig = _lineNumberStyleSignature(style); 6473 6474 _ensureLineNumberResizeObserver(textarea); 6475 if ( 6476 !force && 6477 inner._lineNumberText === value && 6478 inner._lineNumberWidth === textWidth && 6479 inner._lineNumberStyleSig === styleSig 6480 ) { 6481 syncGutterScroll(); 6482 return; 6483 } 6484 6485 const heights = _measureLineNumberHeights(textarea, lines, textWidth, style); 6486 _renderLineNumberRows(inner, heights); 6487 inner._lineNumberText = value; 6488 inner._lineNumberWidth = textWidth; 6489 inner._lineNumberStyleSig = styleSig; 6490 syncGutterScroll(); 6491 } 6492 6493 /** Sync line number gutter scroll with textarea */ 6494 function syncGutterScroll() { 6495 const textarea = document.getElementById('doc-editor-textarea'); 6496 const gutter = document.getElementById('doc-line-numbers'); 6497 if (textarea && gutter) { 6498 _lineNumberContentEl(gutter).style.transform = `translateY(${-textarea.scrollTop}px)`; 6499 } 6500 } 6501 6502 /** Attempt language auto-detection using hljs.highlightAuto() */ 6503 /** Quick heuristic check for markdown before falling back to hljs */ 6504 function _looksLikeMarkdown(text) { 6505 const lines = text.slice(0, 2000).split('\n'); 6506 let score = 0; 6507 for (const line of lines) { 6508 if (/^#{1,6}
6508\s/.test(line)) score += 3; // headings 6509 else if (/^\s*[-*+]\s/.test(line)) score += 1; // list items 6510 else if (/^\s*\d+\.\s/.test(line)) score += 1; // ordered list 6511 else if (/^\s*>/.test(line)) score += 1; // blockquote 6512 else if (/\[.+\]\(.+\)/.test(line)) score += 2; // links 6513 else if (/^```/.test(line)) score += 2; // fenced code 6514 else if (/\*\*.+\*\*/.test(line)) score += 1; // bold 6515 else if (/^---\s*$/.test(line)) score += 1; // horizontal rule 6516 } 6517 return score >= 3; 6518 } 6519 6520 function attemptAutoDetect() { 6521 if (!window.hljs || !activeDocId) return; 6522 const doc = docs.get(activeDocId); 6523 if (!doc || doc.userSetLanguage) return; 6524 6525 const textarea = document.getElementById('doc-editor-textarea'); 6526 if (!textarea) return; 6527 6528 const text = textarea.value; 6529 if (text.length < AUTO_DETECT_MIN_CHARS) return; 6530 6531 // SVG heuristic â a standalone <svg> root (optionally after an XML decl / 6532 // doctype). hljs would tag this generic "xml"; we want it labeled svg so it 6533 // routes to the preview iframe with a correct type. 6534 if (/^\s*(<\?xml[^>]*>\s*)?(<!doctype[^>]*>\s*)?<svg[\s>]/i.test(text)) { 6535 const langSelect = document.getElementById('doc-language-select'); 6536 if (langSelect && langSelect.value !== 'svg') { 6537 langSelect.value = 'svg'; 6538 doc.language = 'svg'; 6539 updateLanguage(); 6540 syncHighlighting(); 6541 _syncHeaderActions(); 6542 } 6543 return; 6544 } 6545 6546 // Markdown heuristic first â hljs often fails to detect it 6547 if (_looksLikeMarkdown(text)) { 6548 const langSelect = document.getElementById('doc-language-select'); 6549 if (langSelect && langSelect.value !== 'markdown') { 6550 langSelect.value = 'markdown'; 6551 doc.language = 'markdown'; 6552 updateLanguage(); 6553 syncHighlighting(); 6554 _syncHeaderActions(); 6555 const mdToolbar = document.getElementById('doc-md-toolbar'); 6556 if (mdToolbar) { mdToolbar.style.display = ''; if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); } 6557 } 6558 return; 6559 } 6560 6561 const sample = text.slice(0, AUTO_DETECT_SAMPLE_SIZE); 6562 const result = window.hljs.highlightAuto(sample); 6563 6564 if (!result.language || result.relevance < AUTO_DETECT_MIN_RELEVANCE) return; 6565 6566 const mapped = HLJS_TO_DROPDOWN[result.language]; 6567 if (!mapped) return; 6568 6569 const langSelect = document.getElementById('doc-language-select'); 6570 if (!langSelect || langSelect.value === mapped) return; 6571 6572 langSelect.value = mapped; 6573 doc.language = mapped; 6574 updateLanguage(); 6575 syncHighlighting(); 6576 _syncHeaderActions(); 6577 6578 const mdToolbar2 = document.getElementById('doc-md-toolbar'); 6579 if (mdToolbar2) mdToolbar2.style.display = (mapped === 'markdown') ? '' : 'none'; 6580 } 6581 6582 // ---- Selection-based AI editing ---- 6583 6584 // Tracked selection state â when set, the next chat message auto-includes this context 6585 let _selections = []; // [{ text, startLine, endLine, start, end }, ...] 6586 6587 // Pinned-selection overlays are positioned in pixel coords measured 6588 // against the textarea's current size. When the window shrinks (or 6589 // the sidebar collapses, or the panel resizes), the text wraps to 6590 // more rows but the overlay rectangles stay where they were â 6591 // visibly drifting off the real highlighted text. Re-render on any 6592 // size change so the overlays follow the new wrap. Debounced via 6593 // rAF to coalesce the rapid-fire ResizeObserver pulses during a 6594 // drag-resize. 6595 let _selResizeScheduled = false; 6596 function _scheduleSelRerender() { 6597 if (_selResizeScheduled || _selections.length === 0) return; 6598 _selResizeScheduled = true; 6599 requestAnimationFrame(() => { 6600 _selResizeScheduled = false; 6601 try { renderAllSelectionHighlights(); } catch (_) {} 6602 }); 6603 } 6604 if (typeof window !== 'undefined') { 6605 window.addEventListener('resize', _scheduleSelRerender); 6606 } 6607 // Observe the textarea itself so internal layout changes (sidebar 6608 // collapse, panel snap, mobile keyboard show/hide) also trigger a 6609 // re-render. The observer attaches lazily on first selection so we 6610 // don't churn before the editor mounts. 6611 let _selResizeObserver = null; 6612 function _ensureSelResizeObserver() { 6613 if (_selResizeObserver || typeof ResizeObserver === 'undefined') return; 6614 const ta = document.getElementById('doc-editor-textarea'); 6615 if (!ta) return; 6616 _selResizeObserver = new ResizeObserver(_scheduleSelRerender); 6617 _selResizeObserver.observe(ta); 6618 } 6619 6620 // Detect whether the textarea is currently wrapping any line. If 6621 // every logical line fits on one visual row, the overlay positions 6622 // are exact and pinned selections are safe regardless of fullscreen 6623 // state. We compute rendered-row-count from scrollHeight/line-height 6624 // and compare against the number of \n-separated lines. 6625 function _textareaWraps(ta) { 6626 if (!ta) return false;
6627 const style = getComputedStyle(ta); 6628 const lh = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45); 6629 if (!lh) return false; 6630 const padTop = parseFloat(style.paddingTop) || 0; 6631 const padBottom = parseFloat(style.paddingBottom) || 0; 6632 const renderedRows = Math.round((ta.scrollHeight - padTop - padBottom) / lh); 6633 const logicalLines = (ta.value || '').split('\n').length; 6634 return renderedRows > logicalLines; 6635 } 6636 6637 /** Update selection tracking, show badge + persistent highlight. 6638 * Each new selection is added (pinned). Click without selecting to clear all. */ 6639 function updateSelectionState() { 6640 // Pinned selections are safe whenever the overlay measurement can 6641 // be exact. That holds in two cases: (1) fullscreen â width is 6642 // stable, or (2) no line wrapping â every logical \n-line fits on 6643 // one visual row, so character-precise mirror measurement isn't 6644 // needed. Outside both cases, panel resizes / wrap shifts make 6645 // overlays drift, so we no-op. 6646 const _pane = document.querySelector('.doc-editor-pane'); 6647 const _isFs = !!(_pane && _pane.classList.contains('doc-fullscreen')); 6648 const _ta0 = document.getElementById('doc-editor-textarea'); 6649 if (!_isFs && _textareaWraps(_ta0)) { 6650 if (_selections.length) clearSelection(); 6651 return; 6652 } 6653 _ensureSelResizeObserver(); 6654 const textarea = document.getElementById('doc-editor-textarea'); 6655 if (!textarea) return; 6656 6657 const start = textarea.selectionStart; 6658 const end = textarea.selectionEnd; 6659 6660 if (start === end) { 6661 // Simple click â don't clear, user might be clicking into chat 6662 return; 6663 } 6664 6665 const text = textarea.value; 6666 const selectedText = text.substring(start, end); 6667 const startLine = text.substring(0, start).split('\n').length; 6668 const endLine = text.substring(0, end).split('\n').length; 6669 6670 // Check for overlap with existing selection â replace if overlapping 6671 const overlapIdx = _selections.findIndex(s => 6672 (start >= s.start && start <= s.end) || (end >= s.start && end <= s.end) || 6673 (start <= s.start && end >= s.end) 6674 ); 6675 const entry = { text: selectedText, startLine, endLine, start, end }; 6676 if (overlapIdx >= 0) { 6677 _selections[overlapIdx] = entry; 6678 } else { 6679 _selections.push(entry); 6680 } 6681 6682 showSelectionBadge(); 6683 renderAllSelectionHighlights(); 6684 } 6685 6686 /** Show a selection indicator badge with count + clear button */ 6687 function showSelectionBadge() { 6688 let badge = document.getElementById('doc-selection-badge'); 6689 if (!badge) { 6690 badge = document.createElement('span'); 6691 badge.id = 'doc-selection-badge'; 6692 badge.className = 'doc-selection-badge'; 6693 badge.title = 'Selected regions â type in chat to edit'; 6694 // Sits directly under the formatting toolbar so it reads as part 6695 // of the toolbar row, not buried in the page header. Falls back 6696 // to the editor header if the toolbar isn't on screen. 6697 const toolbar = document.getElementById('doc-md-toolbar'); 6698 if (toolbar && toolbar.parentNode) { 6699 toolbar.insertAdjacentElement('afterend', badge); 6700 } else { 6701 const header = document.querySelector('.doc-editor-header'); 6702 if (header) header.insertBefore(badge, header.firstChild); 6703 } 6704 } 6705 if (_selections.length === 0) { 6706 badge.style.display = 'none'; 6707 return; 6708 } 6709 const labels = _selections.map(s => 6710 s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}-${s.endLine}` 6711 ); 6712 const label = _selections.length === 1 6713 ? `${labels[0]} selected` 6714 : `${_selections.length} selections (${labels.join(', ')})`; 6715 badge.innerHTML = `${label}<button class="doc-selection-clear" title="Clear all selections">×</button>`; 6716 badge.style.display = ''; 6717 badge.querySelector('.doc-selection-clear').addEventListener('click', (e) => { 6718 e.stopPropagation(); 6719 clearSelection(); 6720 }); 6721 } 6722 6723 /** Markdown / prose docs get character-precise highlights (like a 6724 * normal browser selection but persistent). Code docs get line-based 6725 * highlights â when working in code you usually operate on whole 6726 * lines, and the character-based version reads as jittery against 6727 * monospace alignment. */ 6728 function _isCodeDoc() { 6729 const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase(); 6730 if (!lang) return false;
6731 // Prose / preview types that should get character-precise highlights. 6732 const prose = new Set(['markdown', 'md', 'text', 'txt', 'email', 'html', 'csv']); 6733 return !prose.has(lang); 6734 } 6735 6736 /** Measure the visual x,y position of a character index inside the 6737 * mirror element by inserting a zero-width marker span there and 6738 * reading its bounding rect. Returns {x, y} relative to the mirror's 6739 * content-box origin. */ 6740 function _measurePos(mirror, text, pos) { 6741 mirror.innerHTML = ''; 6742 if (pos > 0) mirror.appendChild(document.createTextNode(text.substring(0, pos))); 6743 const marker = document.createElement('span'); 6744 marker.textContent = 'â'; 6745 mirror.appendChild(marker); 6746 const r = marker.getBoundingClientRect(); 6747 const m = mirror.getBoundingClientRect(); 6748 return { x: r.left - m.left, y: r.top - m.top }; 6749 } 6750 6751 /** Render persistent highlight overlays for all selections */ 6752 // Re-anchor pinned selections against the live textarea content. After 6753 // an undo or any other path that shrinks/shifts the text, captured 6754 // {start, end} positions can point at unrelated content (or past the 6755 // end of the buffer). We: 6756 // 1. Verify the captured text still sits at [start, end]. 6757 // 2. If not, look for the captured text elsewhere in the doc and 6758 // re-anchor. Prefers the nearest occurrence to the old position. 6759 // 3. If the captured text is gone entirely, drop the selection. 6760 // 4. Refresh derived fields (startLine/endLine) when re-anchored. 6761 // Cheap O(N) per selection; only runs when _selections is non-empty. 6762 function _validateSelections(text) { 6763 if (_selections.length === 0) return; 6764 const survivors = []; 6765 for (const s of _selections) { 6766 const captured = s.text || ''; 6767 if (!captured) continue; 6768 // Fast path: still at the same offsets. 6769 if (text.substring(s.start, s.end) === captured) { 6770 survivors.push(s); 6771 continue; 6772 } 6773 // Re-anchor: find the captured text and pick the occurrence 6774 // nearest to the old start so multi-match docs don't snap to the 6775 // wrong one. indexOf scans are cheap for typical doc sizes. 6776 let best = -1, bestDist = Infinity; 6777 let from = 0; 6778 while (true) { 6779 const idx = text.indexOf(captured, from); 6780 if (idx === -1) break; 6781 const dist = Math.abs(idx - s.start); 6782 if (dist < bestDist) { best = idx; bestDist = dist; } 6783 from = idx + 1; 6784 } 6785 if (best === -1) continue; // text gone entirely â drop 6786 const newStart = best; 6787 const newEnd = best + captured.length; 6788 survivors.push({ 6789 ...s, 6790 start: newStart, 6791 end: newEnd, 6792 startLine: text.substring(0, newStart).split('\n').length, 6793 endLine: text.substring(0, newEnd).split('\n').length, 6794 }); 6795 } 6796 _selections = survivors; 6797 } 6798 6799 function renderAllSelectionHighlights() { 6800 const wrap = document.getElementById('doc-editor-wrap'); 6801 if (!wrap) return; 6802 // Remove old overlays 6803 wrap.querySelectorAll('.doc-selection-overlay').forEach(el => el.remove()); 6804 6805 const textarea = document.getElementById('doc-editor-textarea'); 6806 if (!textarea || _selections.length === 0) return; 6807 6808 const text = textarea.value; 6809 // Pre-render guard: re-anchor or drop selections whose text has 6810 // shifted (undo, programmatic edits, etc.) so the overlays never 6811 // draw on the wrong region. 6812 _validateSelections(text); 6813 if (_selections.length === 0) return; 6814 const style = getComputedStyle(textarea); 6815 const paddingTop = parseFloat(style.paddingTop) || 10; 6816 const paddingLeft = parseFloat(style.paddingLeft) || 48; 6817 const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45); 6818 6819 // Shared mirror for measurement â same box model as the textarea 6820 // so any measurement we take lines up 1:1 with the rendered text. 6821 let mirror = document.getElementById('doc-selection-mirror'); 6822 if (!mirror) { 6823 mirror = document.createElement('div'); 6824 mirror.id = 'doc-selection-mirror'; 6825 // box-sizing:border-box is critical â without it the mirror's 6826 // actual box width = (width prop) + horizontal padding, which is 6827 // wider than the textarea's text-render area. Text wraps at a
6828 // different column inside the mirror, so every measured y-offset 6829 // drifts from where the real text sits. border-box makes 6830 // mirror.box = textarea.clientWidth exactly. 6831 mirror.style.cssText = 'position:absolute;top:0;left:0;right:0;visibility:hidden;pointer-events:none;' + 6832 'white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;overflow:hidden;box-sizing:border-box;'; 6833 wrap.appendChild(mirror); 6834 } 6835 mirror.style.font = style.font; 6836 mirror.style.padding = style.padding; 6837 mirror.style.borderWidth = style.borderWidth; 6838 mirror.style.borderStyle = 'solid'; 6839 mirror.style.borderColor = 'transparent'; 6840 mirror.style.width = textarea.clientWidth + 'px'; 6841 mirror.style.tabSize = style.tabSize; 6842 mirror.style.letterSpacing = style.letterSpacing; 6843 mirror.style.wordSpacing = style.wordSpacing; 6844 mirror.style.textIndent = style.textIndent; 6845 6846 const codeDoc = _isCodeDoc(); 6847 const scrollTop = textarea.scrollTop; 6848 6849 for (const sel of _selections) { 6850 if (codeDoc) { 6851 // Line-based: span every line that contains any selected char. 6852 const beforeStart = text.substring(0, sel.start); 6853 const lastNewline = beforeStart.lastIndexOf('\n'); 6854 const startLineBegin = lastNewline + 1; 6855 mirror.textContent = text.substring(0, startLineBegin); 6856 const startTop = mirror.scrollHeight - paddingTop; 6857 6858 const afterEnd = text.indexOf('\n', sel.end); 6859 const endLineEnd = afterEnd === -1 ? text.length : afterEnd; 6860 mirror.textContent = text.substring(0, endLineEnd); 6861 const endBottom = mirror.scrollHeight - paddingTop; 6862 6863 mirror.textContent = ''; 6864 6865 const top = paddingTop + startTop - scrollTop; 6866 const height = endBottom - startTop || lineHeight; 6867 const overlay = document.createElement('div'); 6868 overlay.className = 'doc-selection-overlay'; 6869 overlay.style.top = top + 'px'; 6870 overlay.style.left = paddingLeft + 'px'; 6871 overlay.style.right = '0'; 6872 overlay.style.height = height + 'px'; 6873 wrap.appendChild(overlay); 6874 } else { 6875 // Character-precise: measure the actual selection start/end via 6876 // a marker span. Render one rect for single-line selections, or 6877 // three rects (first partial, middle full, last partial) for 6878 // multi-line selections. 6879 const startPos = _measurePos(mirror, text, sel.start); 6880 const endPos = _measurePos(mirror, text, sel.end); 6881 mirror.innerHTML = ''; 6882 6883 const addRect = (top, left, width, height) => { 6884 const overlay = document.createElement('div'); 6885 overlay.className = 'doc-selection-overlay'; 6886 overlay.style.top = (paddingTop + top - scrollTop) + 'px'; 6887 overlay.style.left = (paddingLeft + left) + 'px'; 6888 if (width != null) overlay.style.width = width + 'px'; 6889 else overlay.style.right = '0'; 6890 overlay.style.height = height + 'px'; 6891 wrap.appendChild(overlay); 6892 }; 6893 6894 if (Math.abs(endPos.y - startPos.y) < 1) { 6895 // Single visual line. 6896 addRect(startPos.y, startPos.x, endPos.x - startPos.x, lineHeight); 6897 } else { 6898 // First line: from selection start to right edge. 6899 addRect(startPos.y, startPos.x, null, lineHeight); 6900 // Middle lines (if any): full-width band between the two. 6901 const middleTop = startPos.y + lineHeight; 6902 const middleHeight = endPos.y - middleTop; 6903 if (middleHeight > 0) addRect(middleTop, 0, null, middleHeight); 6904 // Last line: from left edge to selection end. 6905 addRect(endPos.y, 0, endPos.x, lineHeight); 6906 } 6907 } 6908 } 6909 } 6910 6911 /** Sync all selection highlight positions on scroll */ 6912 function syncSelectionOverlay() { 6913 if (_selections.length === 0) return; 6914 renderAllSelectionHighlights(); 6915 } 6916 6917 /** Clear all selections, badge, and highlights */ 6918 function clearSelection() { 6919 _selections = []; 6920 const badge = document.getElementById('doc-selection-badge'); 6921 if (badge) badge.style.display = 'none'; 6922 const wrap = document.getElementById('doc-editor-wrap'); 6923 if (wrap) wrap.querySelectorAll('.doc-selection-overlay').forEach(el => el.remove()); 6924 } 6925 6926 /** 6927 * Get all selection contexts for chat injection. 6928 * Called by chat module before sending a message. 6929 * Returns null if no selections, or array of { text, startLine, endLine }. 6930 */ 6931 export function getSelectionContext() { 6932 if (_selections.length === 0) return null; 6933 // Re-anchor / drop stale selections before handing them to chat â 6934 // shipping text from a stale offset would mean the AI sees content 6935 // from a different region than what the user thinks they highlighted. 6936 const _ta = document.getElementById('doc-editor-textarea'); 6937 if (_ta) _validateSelections(_ta.value); 6938 if (_selections.length === 0) return null; 6939 if (_selections.length === 1) { 6940 const ctx = _selections[0]; 6941 clearSelection(); 6942 return ctx; 6943 } 6944 // Multiple selections â return array 6945 const ctx = [..._selections]; 6946 clearSelection(); 6947 return ctx; 6948 } 6949 6950 // ââ Inline Suggestion Comments (Google Docs style) ââ 6951 6952 let _activeSuggestions = []; // [{ id, find, replace, reason, highlightEl, bubbleEl }] 6953 6954 /** Persist suggestions to localStorage for the active doc */ 6955 function _saveSuggestionsToStorage() { 6956 if (!activeDocId) return; 6957 const data = _activeSuggestions.map(s => ({ id: s.id, find: s.find, replace: s.replace, reason: s.reason })); 6958 if (data.length) { 6959 localStorage.setItem('odysseus-suggestions-' + activeDocId, JSON.stringify(data)); 6960 } else { 6961 localStorage.removeItem('odysseus-suggestions-' + activeDocId); 6962 } 6963 } 6964 6965 /** Restore suggestions from localStorage for a doc */ 6966 function _restoreSuggestionsFromStorage(docId) { 6967 try { 6968 const raw = localStorage.getItem('odysseus-suggestions-' + docId); 6969 if (!raw) return; 6970 const data = JSON.parse(raw); 6971 if (!Array.isArray(data) || !data.length) return; 6972 _activeSuggestions = data.map(s => ({ id: s.id, find: s.find, replace: s.replace, reason: s.reason, cardEl: null })); 6973 _suggestionTotal = _activeSuggestions.length; 6974 _suggestionIndex = 0; 6975 _showCurrentSuggestion(); 6976 } catch {} 6977 } 6978 6979 /** Handle doc_suggestions SSE event â show one suggestion at a time. 6980 * 6981 * If a previous batch is already pending approval, NEW suggestions are 6982 * appended to the live queue rather than replacing it. The agent (or a 6983 * follow-up batch) can keep adding edits while the user reviews; the count 6984 * and "n of m" header update on the fly. */ 6985 export function handleDocSuggestions(data) { 6986 if (_diffModeActive) exitDiffMode(true); 6987 if (!data.suggestions || !data.suggestions.length) return; 6988
6989 if (!isOpen) openPanel(); 6990 if (data.doc_id && data.doc_id !== activeDocId) switchToDoc(data.doc_id); 6991 6992 const hadPending = _activeSuggestions.length > 0; 6993 const existingIds = new Set(_activeSuggestions.map(s => s.id)); 6994 6995 // Append new suggestions, skipping any IDs already in the queue so a 6996 // re-sent batch doesn't duplicate. 6997 let added = 0; 6998 for (const sugg of data.suggestions) { 6999 if (existingIds.has(sugg.id)) continue; 7000 _activeSuggestions.push({ 7001 id: sugg.id, 7002 find: sugg.find, 7003 replace: sugg.replace, 7004 reason: sugg.reason, 7005 cardEl: null, 7006 }); 7007 added++; 7008 } 7009 _suggestionTotal = (_suggestionTotal || 0) + added; 7010 7011 _saveSuggestionsToStorage(); 7012 7013 // If nothing was pending before, kick off the visual flow. Otherwise the 7014 // currently-shown suggestion stays on screen and the queue size update is 7015 // reflected in the next card's header. 7016 if (!hadPending) { 7017 _suggestionIndex = 0; 7018 _showCurrentSuggestion(); 7019 } else { 7020 // Refresh just the counter in the active card so the user sees the 7021 // queue grew while they were deliberating. 7022 const active = document.getElementById('doc-suggestion-active'); 7023 if (active) { 7024 const counter = active.querySelector('.doc-suggestion-counter'); 7025 if (counter) { 7026 const num = _suggestionTotal - _activeSuggestions.length + 1; 7027 counter.textContent = `${num} / ${_suggestionTotal}`; 7028 } 7029 } 7030 } 7031 } 7032 7033 /** Render the current suggestion card (one at a time) + inline diff in document */ 7034 function _showCurrentSuggestion() { 7035 const wrap = document.getElementById('doc-editor-wrap'); 7036 const pane = document.querySelector('.doc-editor-pane'); 7037 if (!wrap || !pane) return; 7038 7039 // Remove previous card and inline diff 7040 const old = document.getElementById('doc-suggestion-active'); 7041 if (old) { if (old._cleanup) old._cleanup(); old.remove(); } 7042 _clearSuggestionHighlight(); 7043 _clearInlineDiff(); 7044 7045 if (_activeSuggestions.length === 0) { 7046 return; 7047 } 7048 7049 const sugg = _activeSuggestions[0]; 7050 const remaining = _activeSuggestions.length; 7051 const num = _suggestionTotal - remaining + 1; 7052 7053 // Show inline diff in the document 7054 _showInlineDiff(sugg.find, sugg.replace); 7055 7056 const textarea = document.getElementById('doc-editor-textarea'); 7057 7058 // Scroll to the change text 7059 if (textarea) { 7060 const text = textarea.value; 7061 const idx = text.indexOf(sugg.find); 7062 if (idx >= 0) { 7063 const lineNum = text.substring(0, idx).split('\n').length - 1; 7064 const lineH = parseFloat(getComputedStyle(textarea).lineHeight) || 20; 7065 const target = Math.max(0, lineNum * lineH - (textarea.clientHeight / 3)); 7066 textarea.scrollTop = target; 7067 } 7068 } 7069 7070 // Position card next to the highlighted text 7071 function _positionCard(card) { 7072 if (!textarea) return; 7073 const text = textarea.value; 7074 const idx = text.indexOf(sugg.find); 7075 if (idx < 0) return; 7076 7077 const linesBefore = text.substring(0, idx).split('\n').length - 1; 7078 const lineH = parseFloat(getComputedStyle(textarea).lineHeight) || 20; 7079 const textareaRect = textarea.getBoundingClientRect(); 7080 const paddingTop = parseFloat(getComputedStyle(textarea).paddingTop) || 10; 7081 const rawTop = textareaRect.top + paddingTop + (linesBefore * lineH) - textarea.scrollTop; 7082 const clampedTop = Math.max(60, Math.min(rawTop, window.innerHeight - 220)); 7083 card.style.position = 'fixed'; 7084 card.style.top = clampedTop + 'px'; 7085 7086 const paneRect = pane.getBoundingClientRect(); 7087 const isMobile = window.innerWidth <= 768; 7088 if (!isMobile) { 7089 if (paneRect.right + 270 < window.innerWidth) { 7090 card.style.left = (paneRect.right + 16) + 'px'; 7091 card.style.right = ''; 7092 } else { 7093 card.style.left = ''; 7094 card.style.right = (window.innerWidth - paneRect.left + 16) + 'px'; 7095 } 7096 } 7097 7098 // Also position the highlight overlay 7099 _clearSuggestionHighlight(); 7100 _highlightSuggestionText(sugg.find); 7101 } 7102 7103 // Build the card 7104 const card = document.createElement('div'); 7105 card.id = 'doc-suggestion-active'; 7106 card.className = 'doc-suggestion-card'; 7107 7108 card.innerHTML = ` 7109 <div class="doc-suggestion-header"> 7110 <div class="doc-suggestion-nav"> 7111 <button class="doc-suggestion-nav-btn doc-suggestion-prev" title="Previous">‹</button>
7112 <span class="doc-suggestion-counter">${num} / ${_suggestionTotal}</span> 7113 <button class="doc-suggestion-nav-btn doc-suggestion-next" title="Next">›</button> 7114 </div> 7115 <button class="doc-suggestion-close" title="Close all suggestions">×</button> 7116 </div> 7117 <div class="doc-suggestion-reason">${_esc(sugg.reason)}</div> 7118 <div class="doc-suggestion-actions"> 7119 <button class="doc-suggestion-accept">Accept</button> 7120 <button class="doc-suggestion-dismiss">Skip</button> 7121 ${remaining > 1 ? '<button class="doc-suggestion-accept-all">Accept All</button>' : ''} 7122 </div> 7123 `; 7124 7125 // Wire buttons 7126 card.querySelector('.doc-suggestion-close').addEventListener('click', clearAllSuggestions); 7127 card.querySelector('.doc-suggestion-prev').addEventListener('click', () => { 7128 const current = _activeSuggestions.shift(); 7129 _activeSuggestions.push(current); 7130 const prev = _activeSuggestions.pop(); 7131 _activeSuggestions.unshift(prev); 7132 _suggestionIndex = (_suggestionIndex - 1 + _suggestionTotal) % _suggestionTotal; 7133 _showCurrentSuggestion(); 7134 }); 7135 card.querySelector('.doc-suggestion-next').addEventListener('click', () => { 7136 const current = _activeSuggestions.shift(); 7137 _activeSuggestions.push(current); 7138 _suggestionIndex = (_suggestionIndex + 1) % _suggestionTotal; 7139 _showCurrentSuggestion(); 7140 }); 7141 card.querySelector('.doc-suggestion-accept').addEventListener('click', () => { 7142 _applySuggestion(sugg); 7143 _activeSuggestions.shift(); 7144 _animateNext(); 7145 }); 7146 card.querySelector('.doc-suggestion-dismiss').addEventListener('click', () => { 7147 _activeSuggestions.shift(); 7148 _animateNext(); 7149 }); 7150 const acceptAllBtn = card.querySelector('.doc-suggestion-accept-all'); 7151 if (acceptAllBtn) { 7152 acceptAllBtn.addEventListener('click', () => { 7153 for (const s of _activeSuggestions) _applySuggestion(s); 7154 _activeSuggestions = []; 7155 _animateNext(); 7156 }); 7157 } 7158 7159 sugg.cardEl = card; 7160 document.body.appendChild(card); 7161 7162 // Position after a tick so scroll has taken effect 7163 requestAnimationFrame(() => _positionCard(card)); 7164 7165 // Reposition on scroll/resize so the card stays anchored 7166 const _reposition = () => { if (card.isConnected) _positionCard(card); }; 7167 if (textarea) textarea.addEventListener('scroll', _reposition); 7168 window.addEventListener('resize', _reposition); 7169 // Store cleanup refs on the card 7170 card._cleanup = () => { 7171 if (textarea) textarea.removeEventListener('scroll', _reposition); 7172 window.removeEventListener('resize', _reposition); 7173 }; 7174 } 7175 7176 /** Show inline diff by modifying the code highlight element directly */ 7177 function _showInlineDiff(findText, replaceText) { 7178 const codeEl = document.getElementById('doc-editor-code'); 7179 const textarea = document.getElementById('doc-editor-textarea'); 7180 if (!codeEl || !textarea) return; 7181 7182 const text = textarea.value; 7183 const idx = text.indexOf(findText); 7184 if (idx === -1) return; 7185 7186 const before = text.substring(0, idx); 7187 const after = text.substring(idx + findText.length); 7188 7189 // Character-level diff 7190 let cPre = 0; 7191 while (cPre < findText.length && cPre < replaceText.length && findText[cPre] === replaceText[cPre]) cPre++; 7192 let cSuf = 0; 7193 while (cSuf < (findText.length - cPre) && cSuf < (replaceText.length - cPre) && 7194 findText[findText.length - 1 - cSuf] === replaceText[replaceText.length - 1 - cSuf]) cSuf++; 7195 7196 const commonBefore = findText.substring(0, cPre); 7197 const commonAfter = findText.substring(findText.length - cSuf); 7198 const delPart = findText.substring(cPre, findText.length - cSuf); 7199 const addPart = replaceText.substring(cPre, replaceText.length - cSuf); 7200 7201 // Replace codeEl content with diff-marked version 7202 codeEl.innerHTML = ''; 7203 codeEl.appendChild(document.createTextNode(before)); 7204 if (commonBefore) codeEl.appendChild(document.createTextNode(commonBefore)); 7205 7206 if (delPart) { 7207 const del = document.createElement('span'); 7208 del.className = 'sugg-inline-del'; 7209 del.textContent = delPart; 7210 codeEl.appendChild(del); 7211 } 7212 if (addPart) { 7213 const add = document.createElement('span'); 7214 add.className = 'sugg-inline-add'; 7215 add.textContent = addPart; 7216 codeEl.appendChild(add); 7217 } 7218 7219 if (commonAfter) codeEl.appendChild(document.createTextNode(commonAfter)); 7220 codeEl.appendChild(document.createTextNode(after + '\n')); 7221 7222 // Mark that we have an active diff so syncHighlighting doesn't overwrite it 7223 codeEl.dataset.hasDiff = '1'; 7224 } 7225 7226 /** Clear inline diff â restore normal highlighting */ 7227 function _clearInlineDiff() { 7228 const codeEl = document.getElementById('doc-editor-code'); 7229 if (codeEl && codeEl.dataset.hasDiff) { 7230 delete codeEl.dataset.hasDiff;
7231 syncHighlighting(); 7232 } 7233 } 7234 7235 // ---- Diff mode (line-level review) ---- 7236 7237 const DIFF_MODE_THRESHOLD = 3; // min changed lines to trigger diff mode 7238 7239 /** Line-level LCS diff algorithm */ 7240 function _computeLineDiff(oldText, newText) { 7241 const oldLines = oldText.split('\n'); 7242 const newLines = newText.split('\n'); 7243 const m = oldLines.length, n = newLines.length; 7244 7245 // Build LCS table 7246 const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1)); 7247 for (let i = 1; i <= m; i++) { 7248 for (let j = 1; j <= n; j++) { 7249 dp[i][j] = oldLines[i - 1] === newLines[j - 1] 7250 ? dp[i - 1][j - 1] + 1 7251 : Math.max(dp[i - 1][j], dp[i][j - 1]); 7252 } 7253 } 7254 7255 // Backtrack to produce diff entries 7256 const entries = []; 7257 let i = m, j = n; 7258 while (i > 0 || j > 0) { 7259 if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { 7260 entries.push({ type: 'equal', line: oldLines[i - 1] }); 7261 i--; j--; 7262 } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { 7263 entries.push({ type: 'insert', line: newLines[j - 1] }); 7264 j--; 7265 } else { 7266 entries.push({ type: 'delete', line: oldLines[i - 1] }); 7267 i--; 7268 } 7269 } 7270 entries.reverse(); 7271 return entries; 7272 } 7273 7274 /** Group diff entries into chunks (contiguous change blocks) */ 7275 function _buildDiffChunks(entries) { 7276 const chunks = []; 7277 let chunkId = 0; 7278 let lineIdx = 0; 7279 let i = 0; 7280 while (i < entries.length) { 7281 const e = entries[i]; 7282 if (e.type === 'equal') { 7283 lineIdx++; 7284 i++; 7285 } else { 7286 // Gather contiguous non-equal entries into a chunk 7287 const startLine = lineIdx; 7288 const oldLines = [], newLines = []; 7289 while (i < entries.length && entries[i].type !== 'equal') { 7290 if (entries[i].type === 'delete') oldLines.push(entries[i].line); 7291 else newLines.push(entries[i].line); 7292 i++; 7293 } 7294 chunks.push({ 7295 id: chunkId++, 7296 oldLines, 7297 newLines, 7298 startLine, 7299 resolved: false, 7300 accepted: false, 7301 }); 7302 lineIdx += oldLines.length + newLines.length; 7303 } 7304 } 7305 return chunks; 7306 } 7307 7308 /** Enter diff mode â show line-level diff for review */ 7309 function enterDiffMode(oldContent, newContent) { 7310 if (_diffModeActive) exitDiffMode(true); 7311 7312 _diffModeActive = true; 7313 _diffOldContent = oldContent; 7314 _diffNewContent = newContent; 7315 7316 const entries = _computeLineDiff(oldContent, newContent); 7317 _diffChunks = _buildDiffChunks(entries); 7318 _diffUnresolvedCount = _diffChunks.length; 7319 7320 if (_diffChunks.length === 0) { 7321 _diffModeActive = false; 7322 if (uiModule) uiModule.showToast('No changes'); 7323 return; 7324 } 7325 7326 const textarea = document.getElementById('doc-editor-textarea'); 7327 if (textarea) textarea.readOnly = true; 7328 const wrap = document.getElementById('doc-editor-wrap'); 7329 if (wrap) wrap.classList.add('diff-mode'); 7330 7331 _renderDiffOverlay(entries); 7332 _renderDiffToolbar(); 7333 _renderDiffGutter(); 7334 7335 // Update header button 7336 const diffBtn = document.getElementById('doc-diff-toggle-btn'); 7337 if (diffBtn) diffBtn.classList.add('active'); 7338 } 7339 7340 /** Render the line-level diff into the code highlight element */ 7341 function _renderDiffOverlay(entries) { 7342 const codeEl = document.getElementById('doc-editor-code'); 7343 const gutter = document.getElementById('doc-line-numbers'); 7344 if (!codeEl) return; 7345 7346 codeEl.innerHTML = ''; 7347 let gutterHtml = ''; 7348 let oldNum = 0, newNum = 0; 7349 7350 // Pre-assign chunk IDs to entries by walking chunks and entries together 7351 let chunkIdx = 0; 7352 let entryIdx = 0; 7353 const entryChunkMap = new Array(entries.length).fill(-1); 7354 while (entryIdx < entries.length) { 7355 if (entries[entryIdx].type === 'equal') { 7356 entryIdx++; 7357 } else { 7358 // This is the start of a change block â assign all contiguous non-equal entries to the current chunk 7359 const cid = chunkIdx < _diffChunks.length ? _diffChunks[chunkIdx].id : -1;
7360 while (entryIdx < entries.length && entries[entryIdx].type !== 'equal') { 7361 entryChunkMap[entryIdx] = cid; 7362 entryIdx++; 7363 } 7364 chunkIdx++; 7365 } 7366 } 7367 7368 for (let i = 0; i < entries.length; i++) { 7369 const e = entries[i]; 7370 if (e.type === 'equal') { 7371 oldNum++; newNum++; 7372 const el = document.createElement('span'); 7373 el.className = 'diff-line-equal'; 7374 el.textContent = e.line + '\n'; 7375 codeEl.appendChild(el); 7376 gutterHtml += newNum + '\n'; 7377 } else if (e.type === 'delete') { 7378 oldNum++; 7379 const el = document.createElement('span'); 7380 el.className = 'diff-line-del'; 7381 if (entryChunkMap[i] >= 0) el.dataset.chunkId = entryChunkMap[i]; 7382 el.textContent = e.line + '\n'; 7383 codeEl.appendChild(el); 7384 gutterHtml += 'â\n'; 7385 } else { 7386 newNum++; 7387 const el = document.createElement('span'); 7388 el.className = 'diff-line-add'; 7389 if (entryChunkMap[i] >= 0) el.dataset.chunkId = entryChunkMap[i]; 7390 el.textContent = e.line + '\n'; 7391 codeEl.appendChild(el); 7392 gutterHtml += '+\n'; 7393 } 7394 } 7395 7396 if (gutter) gutter.textContent = gutterHtml; 7397 codeEl.dataset.hasDiff = '1'; 7398 7399 // Sync textarea to show the combined view (old + new interleaved) for scroll sizing 7400 const textarea = document.getElementById('doc-editor-textarea'); 7401 if (textarea) { 7402 const allLines = entries.map(e => e.line); 7403 textarea.value = allLines.join('\n') + '\n'; 7404 } 7405 } 7406 7407 /** Render the diff toolbar above the editor */ 7408 function _renderDiffToolbar() { 7409 let toolbar = document.getElementById('doc-diff-toolbar'); 7410 if (toolbar) toolbar.remove(); 7411 7412 toolbar = document.createElement('div'); 7413 toolbar.id = 'doc-diff-toolbar'; 7414 toolbar.className = 'diff-toolbar'; 7415 7416 const status = document.createElement('span'); 7417 status.className = 'diff-toolbar-status'; 7418 status.id = 'diff-toolbar-status'; 7419 _updateDiffStatus(status); 7420 7421 const acceptAll = document.createElement('button'); 7422 acceptAll.className = 'diff-toolbar-btn diff-toolbar-btn-accept'; 7423 acceptAll.textContent = 'Accept All'; 7424 acceptAll.addEventListener('click', () => _resolveAllChunks(true)); 7425 7426 const rejectAll = document.createElement('button'); 7427 rejectAll.className = 'diff-toolbar-btn diff-toolbar-btn-reject'; 7428 rejectAll.textContent = 'Reject All'; 7429 rejectAll.addEventListener('click', () => _resolveAllChunks(false)); 7430 7431 toolbar.appendChild(status); 7432 toolbar.appendChild(acceptAll); 7433 toolbar.appendChild(rejectAll); 7434 7435 const wrap = document.getElementById('doc-editor-wrap'); 7436 if (wrap) wrap.parentNode.insertBefore(toolbar, wrap); 7437 } 7438 7439 /** Render per-chunk accept/reject buttons in a gutter overlay */ 7440 function _renderDiffGutter() { 7441 let gutterEl = document.getElementById('doc-diff-gutter'); 7442 if (gutterEl) gutterEl.remove(); 7443 7444 gutterEl = document.createElement('div'); 7445 gutterEl.id = 'doc-diff-gutter'; 7446 gutterEl.className = 'diff-gutter'; 7447 7448 const codeEl = document.getElementById('doc-editor-code'); 7449 if (!codeEl) return; 7450 7451 // Insert chunk action buttons directly next to the first changed line of each chunk 7452 // This way they scroll naturally with the content 7453 requestAnimationFrame(() => { 7454 for (const chunk of _diffChunks) { 7455 if (chunk.resolved) continue; 7456 const firstEl = codeEl.querySelector(`[data-chunk-id="${chunk.id}"]`); 7457 if (!firstEl) continue; 7458 7459 const actions = document.createElement('span'); 7460 actions.className = 'diff-chunk-actions'; 7461 actions.dataset.chunkId = chunk.id; 7462 7463 const acceptBtn = document.createElement('button'); 7464 acceptBtn.className = 'diff-chunk-btn diff-chunk-btn-accept'; 7465 acceptBtn.title = 'Accept change'; 7466 acceptBtn.innerHTML = 'â'; 7467 acceptBtn.addEventListener('click', (e) => { e.stopPropagation(); _resolveChunk(chunk.id, true); }); 7468 7469 const rejectBtn = document.createElement('button'); 7470 rejectBtn.className = 'diff-chunk-btn diff-chunk-btn-reject'; 7471 rejectBtn.title = 'Reject change'; 7472 rejectBtn.innerHTML = 'â'; 7473 rejectBtn.addEventListener('click', (e) => { e.stopPropagation(); _resolveChunk(chunk.id, false); }); 7474 7475 actions.appendChild(acceptBtn); 7476 actions.appendChild(rejectBtn); 7477 7478 // Insert at the start of the first line span 7479 firstEl.style.position = 'relative'; 7480 firstEl.appendChild(actions); 7481 } 7482 }); 7483 } 7484 7485 /** Update the toolbar status text */ 7486 function _updateDiffStatus(statusEl) { 7487 const el = statusEl || document.getElementById('diff-toolbar-status'); 7488 if (!el) return; 7489 const resolved = _diffChunks.length - _diffUnresolvedCount; 7490 el.textContent = `${resolved} / ${_diffChunks.length} changes resolved`; 7491 } 7492 7493 /** Resolve a single chunk */ 7494 function _resolveChunk(chunkId, accept) { 7495 const chunk = _diffChunks.find(c => c.id === chunkId); 7496 if (!chunk || chunk.resolved) return; 7497 7498 chunk.resolved = true; 7499 chunk.accepted = accept; 7500 _diffUnresolvedCount--; 7501 7502 // Fade resolved lines in the overlay 7503 const codeEl = document.getElementById('doc-editor-code'); 7504 if (codeEl) { 7505 codeEl.querySelectorAll(`[data-chunk-id="${chunkId}"]`).forEach(el => { 7506 el.classList.add('diff-chunk-resolved'); 7507 }); 7508 } 7509 7510 // Remove the gutter buttons for this chunk 7511 const gutterActions = document.querySelector(`.diff-chunk-actions[data-chunk-id="${chunkId}"]`); 7512 if (gutterActions) gutterActions.remove(); 7513 7514 _updateDiffStatus(); 7515 7516 // Persist partial progress so refresh doesn't lose individually-resolved chunks 7517 _applyResolvedChunksToTextarea(); 7518 saveDocument({ silent: true }); 7519 7520 if (_diffUnresolvedCount === 0) { 7521 setTimeout(() => exitDiffMode(false), 300); 7522 } 7523 } 7524 7525 /** Compute current content from old + resolved chunk decisions; unresolved chunks 7526 * default to the original (rejected) until the user decides. Updates textarea. */ 7527 function _applyResolvedChunksToTextarea() { 7528 const textarea = document.getElementById('doc-editor-textarea'); 7529 if (!textarea) return; 7530 const entries = _computeLineDiff(_diffOldContent || '', _diffNewContent || ''); 7531 const result = [];
7532 let chunkIdx = 0; 7533 let i = 0; 7534 while (i < entries.length) { 7535 if (entries[i].type === 'equal') { 7536 result.push(entries[i].line); 7537 i++; 7538 } else { 7539 const chunk = _diffChunks[chunkIdx++]; 7540 const chunkOld = [], chunkNew = []; 7541 while (i < entries.length && entries[i].type !== 'equal') { 7542 if (entries[i].type === 'delete') chunkOld.push(entries[i].line); 7543 else chunkNew.push(entries[i].line); 7544 i++; 7545 } 7546 // Resolved+accepted â use new; resolved+rejected OR unresolved â keep old 7547 if (chunk && chunk.resolved && chunk.accepted) { 7548 result.push(...chunkNew); 7549 } else { 7550 result.push(...chunkOld); 7551 } 7552 } 7553 } 7554 textarea.value = result.join('\n'); 7555 } 7556 7557 /** Resolve all chunks at once */ 7558 function _resolveAllChunks(accept) { 7559 for (const chunk of _diffChunks) { 7560 if (!chunk.resolved) { 7561 chunk.resolved = true; 7562 chunk.accepted = accept; 7563 } 7564 } 7565 _diffUnresolvedCount = 0; 7566 exitDiffMode(false); 7567 } 7568 7569 /** Exit diff mode and apply resolved changes */ 7570 function exitDiffMode(discard) { 7571 if (!_diffModeActive) return; 7572 _diffModeActive = false; 7573 7574 const textarea = document.getElementById('doc-editor-textarea'); 7575 const codeEl = document.getElementById('doc-editor-code'); 7576 const wrap = document.getElementById('doc-editor-wrap'); 7577 if (wrap) wrap.classList.remove('diff-mode'); 7578 7579 if (discard) { 7580 // Reject all â restore original content 7581 if (textarea) textarea.value = _diffOldContent || ''; 7582 } else { 7583 // Build final content from resolved chunks 7584 const oldLines = (_diffOldContent || '').split('\n'); 7585 const newLines = (_diffNewContent || '').split('\n'); 7586 const entries = _computeLineDiff(_diffOldContent || '', _diffNewContent || ''); 7587 7588 const result = []; 7589 let chunkIdx = 0; 7590 let i = 0; 7591 while (i < entries.length) { 7592 if (entries[i].type === 'equal') { 7593 result.push(entries[i].line); 7594 i++; 7595 } else { 7596 // Find the matching chunk 7597 const chunk = _diffChunks[chunkIdx++]; 7598 // Skip all entries belonging to this chunk 7599 const chunkOld = [], chunkNew = []; 7600 while (i < entries.length && entries[i].type !== 'equal') { 7601 if (entries[i].type === 'delete') chunkOld.push(entries[i].line); 7602 else chunkNew.push(entries[i].line); 7603 i++; 7604 } 7605 if (chunk && chunk.accepted) { 7606 result.push(...chunkNew); 7607 } else { 7608 result.push(...chunkOld); 7609 } 7610 } 7611 } 7612 if (textarea) textarea.value = result.join('\n'); 7613 } 7614 7615 // Restore editor state 7616 if (textarea) textarea.readOnly = false; 7617 if (codeEl) delete codeEl.dataset.hasDiff; 7618 7619 // Clean up toolbar and any remaining chunk action buttons 7620 const toolbar = document.getElementById('doc-diff-toolbar'); 7621 if (toolbar) toolbar.remove(); 7622 document.querySelectorAll('.diff-chunk-actions').forEach(el => el.remove()); 7623 7624 // Reset state 7625 _diffOldContent = null; 7626 _diffNewContent = null; 7627 _diffChunks = []; 7628 _diffUnresolvedCount = 0; 7629 7630 const diffBtn = document.getElementById('doc-diff-toggle-btn'); 7631 if (diffBtn) diffBtn.classList.remove('active'); 7632 7633 syncHighlighting(); 7634 updateLineNumbers(textarea ? textarea.value : ''); 7635 saveDocument({ silent: true }); 7636 } 7637 7638 /** Check if diff mode is active */ 7639 function isDiffModeActive() { return _diffModeActive; } 7640 7641 let _suggestionTotal = 0; 7642 let _suggestionIndex = 0; 7643 7644 // Override handleDocSuggestions to track total 7645 const _origHandleDocSuggestions = handleDocSuggestions; 7646 // (total is set inside handleDocSuggestions before _showCurrentSuggestion) 7647 7648 /** Apply a single suggestion edit without removing from queue */ 7649 function _applySuggestion(sugg) { 7650 const textarea = document.getElementById('doc-editor-textarea'); 7651 if (textarea && sugg.find && textarea.value.includes(sugg.find)) { 7652 textarea.value = textarea.value.replace(sugg.find, sugg.replace);
7653 syncHighlighting(); 7654 saveDocument({ silent: true }); 7655 } 7656 } 7657 7658 /** Animate transition to next suggestion */ 7659 function _animateNext() { 7660 _saveSuggestionsToStorage(); 7661 const old = document.getElementById('doc-suggestion-active'); 7662 if (old) { 7663 if (old._cleanup) old._cleanup(); 7664 old.style.transition = 'opacity 0.15s, transform 0.15s'; 7665 old.style.opacity = '0'; 7666 old.style.transform = 'translateY(-10px)'; 7667 setTimeout(() => { 7668 old.remove(); 7669 _showCurrentSuggestion(); 7670 }, 150); 7671 } else { 7672 _showCurrentSuggestion(); 7673 } 7674 } 7675 7676 function _esc(s) { 7677 return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 7678 } 7679 7680 /** Accept a suggestion â apply the edit */ 7681 function acceptSuggestion(id) { 7682 const sugg = _activeSuggestions.find(s => s.id === id); 7683 if (!sugg) return; 7684 7685 const textarea = document.getElementById('doc-editor-textarea'); 7686 if (textarea && sugg.find && textarea.value.includes(sugg.find)) { 7687 textarea.value = textarea.value.replace(sugg.find, sugg.replace); 7688 syncHighlighting(); 7689 saveDocument({ silent: true }); 7690 } 7691 7692 // Animate card out 7693 sugg.cardEl.style.transition = 'opacity 0.2s, transform 0.2s'; 7694 sugg.cardEl.style.opacity = '0'; 7695 sugg.cardEl.style.transform = 'translateX(10px)'; 7696 setTimeout(() => sugg.cardEl.remove(), 200); 7697 7698 _activeSuggestions = _activeSuggestions.filter(s => s.id !== id); 7699 _clearSuggestionHighlight(); 7700 7701 // Remove container if empty 7702 if (_activeSuggestions.length === 0) { 7703 const container = document.getElementById('doc-suggestions-container'); 7704 if (container) container.style.display = 'none'; 7705 } 7706 } 7707 7708 /** Dismiss a suggestion â just remove the card */ 7709 function dismissSuggestion(id) { 7710 const sugg = _activeSuggestions.find(s => s.id === id); 7711 if (!sugg) return; 7712 7713 sugg.cardEl.style.transition = 'opacity 0.15s'; 7714 sugg.cardEl.style.opacity = '0'; 7715 setTimeout(() => sugg.cardEl.remove(), 150); 7716 7717 _activeSuggestions = _activeSuggestions.filter(s => s.id !== id); 7718 _clearSuggestionHighlight(); 7719 7720 if (_activeSuggestions.length === 0) { 7721 const container = document.getElementById('doc-suggestions-container'); 7722 if (container) container.style.display = 'none'; 7723 } 7724 } 7725 7726 /** Clear all suggestion cards */ 7727 function clearAllSuggestions() { 7728 _activeSuggestions = []; 7729 _suggestionTotal = 0; 7730 _saveSuggestionsToStorage(); 7731 _clearSuggestionHighlight(); 7732 _clearInlineDiff(); 7733 const old = document.getElementById('doc-suggestion-active'); 7734 if (old) { if (old._cleanup) old._cleanup(); old.remove(); } 7735 const container = document.getElementById('doc-suggestions-container'); 7736 if (container) { container.innerHTML = '';
7736 container.style.display = 'none'; } 7737 // Restore line numbers 7738 const ta = document.getElementById('doc-editor-textarea'); 7739 if (ta) updateLineNumbers(ta.value); 7740 } 7741 7742 /** Highlight the referenced text in the editor when hovering a suggestion */ 7743 function _highlightSuggestionText(findText) { 7744 _clearSuggestionHighlight(); 7745 const textarea = document.getElementById('doc-editor-textarea'); 7746 const wrap = document.getElementById('doc-editor-wrap'); 7747 if (!textarea || !wrap) return; 7748 7749 const text = textarea.value; 7750 const idx = text.indexOf(findText); 7751 if (idx === -1) return; 7752 7753 const style = getComputedStyle(textarea); 7754 const paddingTop = parseFloat(style.paddingTop) || 10; 7755 const paddingLeft = parseFloat(style.paddingLeft) || 48; 7756 const lineHeight = parseFloat(style.lineHeight) || 20; 7757 7758 let mirror = document.getElementById('doc-selection-mirror'); 7759 if (!mirror) return; 7760 7761 const beforeStart = text.substring(0, idx); 7762 const lastNewline = beforeStart.lastIndexOf('\n'); 7763 const startLineBegin = lastNewline + 1; 7764 mirror.textContent = text.substring(0, startLineBegin); 7765 const startTop = mirror.scrollHeight - paddingTop; 7766 7767 const endIdx = idx + findText.length; 7768 const afterEnd = text.indexOf('\n', endIdx); 7769 const endLineEnd = afterEnd === -1 ? text.length : afterEnd; 7770 mirror.textContent = text.substring(0, endLineEnd); 7771 const endBottom = mirror.scrollHeight - paddingTop; 7772 mirror.textContent = ''; 7773 7774 const top = paddingTop + startTop - textarea.scrollTop; 7775 const height = Math.max(endBottom - startTop, lineHeight); 7776 7777 const highlight = document.createElement('div'); 7778 highlight.className = 'doc-suggestion-highlight'; 7779 highlight.id = 'doc-suggestion-hover-hl'; 7780 highlight.style.top = top + 'px'; 7781 highlight.style.left = paddingLeft + 'px'; 7782 highlight.style.right = '0'; 7783 highlight.style.height = height + 'px'; 7784 wrap.appendChild(highlight); 7785 7786 // Don't auto-scroll here â caller handles scrolling 7787 } 7788 7789 /** Remove hover highlight */ 7790 function _clearSuggestionHighlight() { 7791 const hl = document.getElementById('doc-suggestion-hover-hl'); 7792 if (hl) hl.remove(); 7793 } 7794 7795 /** Run the document's code using the in-browser code runner */ 7796 function runDocument() { 7797 const textarea = document.getElementById('doc-editor-textarea'); 7798 if (!textarea || !textarea.value.trim()) return; 7799 7800 const code = textarea.value; 7801 const langSelect = document.getElementById('doc-language-select'); 7802 const lang = (langSelect ? langSelect.value : '').toLowerCase(); 7803 7804 // Get or create the output panel below the editor 7805 let outputPanel = document.getElementById('doc-run-output'); 7806 if (!outputPanel) { 7807 outputPanel = document.createElement('div'); 7808 outputPanel.id = 'doc-run-output'; 7809 outputPanel.className = 'doc-run-output'; 7810 const editorWrap = document.getElementById('doc-editor-wrap'); 7811 if (editorWrap) editorWrap.after(outputPanel); 7812 } 7813 outputPanel.style.display = 'block'; 7814 outputPanel.innerHTML = ''; 7815 7816 if (_isRenderLang(lang)) {
7817 // HTML / SVG / XML â render inline in the sandboxed preview iframe. 7818 outputPanel.style.display = 'none'; 7819 toggleHtmlPreview(); 7820 return; 7821 } 7822 7823 if (!codeRunnerModule) { 7824 outputPanel.innerHTML = '<pre class="doc-run-error">Code runner not loaded</pre>'; 7825 setTimeout(() => { if (outputPanel) outputPanel.style.display = 'none'; }, 5000); 7826 return; 7827 } 7828 7829 if (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') { 7830 codeRunnerModule.runServer(code, outputPanel, 'bash'); 7831 return; 7832 } 7833 7834 if (lang === 'python' || lang === 'py') { 7835 codeRunnerModule.runServer(code, outputPanel, 'python'); 7836 return; 7837 } 7838 7839 if (lang === 'javascript' || lang === 'js') { 7840 codeRunnerModule.runJavaScript(code, outputPanel); 7841 return; 7842 } 7843 7844 outputPanel.innerHTML = '<pre class="doc-run-error">Unsupported language. Supported: bash, python, javascript, html</pre>'; 7845 setTimeout(() => { if (outputPanel) outputPanel.style.display = 'none'; }, 5000); 7846 } 7847 7848 /** Copy document content to clipboard */ 7849 async function copyDocument() { 7850 const textarea = document.getElementById('doc-editor-textarea'); 7851 if (!textarea || !textarea.value) return; 7852 if (uiModule && uiModule.copyToClipboard) { 7853 await uiModule.copyToClipboard(textarea.value); 7854 } else { 7855 try { 7856 await navigator.clipboard.writeText(textarea.value); 7857 } catch (e) { /* ignore */ } 7858 } 7859 if (uiModule) uiModule.showToast('Copied to clipboard'); 7860 } 7861 7862 /* ---- Per-tab context menu ---- */ 7863 7864 let _docTabMenu = null; // singleton dropdown element 7865 7866 function _closeDocTabMenu() { 7867 if (_docTabMenu) { _docTabMenu.style.display = 'none'; } 7868 } 7869 7870 function showDocTabMenu(btnEl, docId) { 7871 // Toggle off if already open for this doc 7872 if (_docTabMenu && _docTabMenu.style.display === 'block' && _docTabMenu._docId === docId) { 7873 _closeDocTabMenu(); 7874 return; 7875 } 7876 7877 // Capture button position before any DOM changes 7878 const _menuAnchorRect = btnEl.getBoundingClientRect(); 7879 7880 // Switch to this doc if not already active 7881 if (docId !== activeDocId) switchToDoc(docId); 7882 7883 const doc = docs.get(docId); 7884 if (!doc) return; 7885 7886 // Create singleton menu container once 7887 if (!_docTabMenu) { 7888 _docTabMenu = document.createElement('div'); 7889 _docTabMenu.className = 'doc-tab-dropdown'; 7890 _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;'; 7891 document.body.appendChild(_docTabMenu); 7892 // Close on outside click 7893 document.addEventListener('click', (e) => { 7894 if (_docTabMenu && !_docTabMenu.contains(e.target) && !e.target.closest('.doc-tab-menu-btn')) { 7895 _closeDocTabMenu(); 7896 } 7897 }); 7898 document.addEventListener('keydown', (e) => { 7899 if (e.key !== 'Escape' || !_docTabMenu || _docTabMenu.style.display !== 'block') return; 7900 e.preventDefault(); 7901 e.stopPropagation(); 7902 e.stopImmediatePropagation?.(); 7903 _closeDocTabMenu(); 7904 }, true); 7905 } 7906 7907 const lang = (doc.language || '').toLowerCase(); 7908 const canRun = _isRenderLang(lang) || ['javascript', 'js', 'python', 'py', 'bash', 'sh', 'shell', 'zsh'].includes(lang); 7909 7910 let previewIcon = '', previewLabel = ''; 7911 const _mdPreview = document.getElementById('doc-md-preview'); 7912 const _csvPreview = document.getElementById('doc-csv-preview'); 7913 const _htmlPreview = document.getElementById('doc-html-preview'); 7914 const _mdActive = _mdPreview && _mdPreview.style.display !== 'none'; 7915 const _csvActive = _csvPreview && _csvPreview.style.display !== 'none'; 7916 const _htmlActive = _htmlPreview && _htmlPreview.style.display !== 'none'; 7917 if (lang === 'markdown') { previewIcon = 'MD'; previewLabel = _mdActive ? 'Edit' : 'Preview'; } 7918 else if (lang === 'csv') { previewIcon = 'â'; previewLabel = _csvActive ? 'Edit' : 'Table View'; } 7919 else if (_isRenderLang(lang)) { previewIcon = 'â¶'; previewLabel = _htmlActive ? 'Edit' : 'Run / Preview'; } 7920 7921 const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
7922 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>'; 7923 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>'; 7924 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>'; 7925 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>'; 7926 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>'; 7927 7928 let items = ''; 7929 items += `<div class="dropdown-item-compact doc-tab-action" data-action="save">${_di(_saveIco)}<span>Save</span></div>`; 7930 items += `<div class="dropdown-item-compact doc-tab-action" data-action="copy">${_di(_copyIco)}<span>Copy</span></div>`; 7931 if (canRun) { 7932 items += `<div class="dropdown-item-compact doc-tab-action" data-action="run">${_di(_runIco)}<span>Run</span></div>`; 7933 } 7934 if (previewLabel) { 7935 items += `<div class="dropdown-item-compact doc-tab-action" data-action="preview"><span class="dropdown-icon">${previewIcon}</span><span>${previewLabel}</span></div>`; 7936 } 7937 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>'; 7938 items += `<div class="dropdown-item-compact doc-tab-action" data-action="download">${_di(_downloadIco)}<span>Download</span></div>`; 7939 // "Send signed reply" â only if this doc was opened from an email attachment 7940 if (doc.sourceEmailUid && doc.sourceEmailFolder) { 7941 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>'; 7942 items += `<div class="dropdown-item-compact doc-tab-action" data-action="signed-reply">${_di(_sendBackIco)}<span>Send signed reply</span></div>`; 7943 } 7944 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>'; 7945 items += `<div class="dropdown-item-compact doc-tab-action" data-action="close">${_di(_closeIco)}<span>Close</span></div>`; 7946 items += `<div class="dropdown-divider"></div>`; 7947 items += `<div class="dropdown-item-compact doc-tab-action doc-tab-action-delete" data-action="delete">${_di(_deleteIco)}<span>Delete</span></div>`; 7948 7949 _docTabMenu.innerHTML = items; 7950 _docTabMenu.style.display = 'block'; 7951 _docTabMenu._docId = docId; 7952 7953 // Position: anchor to the tab bar bottom, aligned to button horizontally 7954 const rect = _menuAnchorRect; 7955 const tabBar = document.getElementById('doc-tab-bar'); 7956 const barBottom = tabBar ? tabBar.getBoundingClientRect().bottom : rect.bottom; 7957 _docTabMenu.style.position = 'fixed'; 7958 _docTabMenu.style.zIndex = '1000'; 7959 _docTabMenu.style.left = rect.left + 'px'; 7960 _docTabMenu.style.top = (barBottom + 2) + 'px'; 7961 7962 // Clamp to viewport edges 7963 requestAnimationFrame(() => { 7964 const menuRect = _docTabMenu.getBoundingClientRect(); 7965 if (menuRect.right > window.innerWidth - 8) { 7966 _docTabMenu.style.left = (window.innerWidth - menuRect.width - 8) + 'px'; 7967 } 7968 if (menuRect.left < 8) { 7969 _docTabMenu.style.left = '8px'; 7970 } 7971 if (menuRect.bottom > window.innerHeight - 8) { 7972 _docTabMenu.style.top = (barBottom - menuRect.height - 4) + 'px'; 7973 } 7974 }); 7975 7976 // Wire action clicks 7977 _docTabMenu.querySelectorAll('.doc-tab-action').forEach(item => { 7978 item.addEventListener('click', (e) => { 7979 e.stopPropagation(); 7980 const action = item.dataset.action; 7981 _closeDocTabMenu(); 7982 switch (action) { 7983 case 'save': saveDocument(); break; 7984 case 'copy': copyDocument(); break; 7985 case 'run': runDocument(); break; 7986 case 'preview': 7987 if (lang === 'markdown') toggleMarkdownPreview(); 7988 else if (lang === 'csv') toggleCsvPreview(); 7989 else if (_isRenderLang(lang)) toggleHtmlPreview(); 7990 break; 7991 case 'download': { 7992 const btn = document.getElementById('doc-fontsize-btn') || document.getElementById('doc-language-select'); 7993 showExportMenu(null, btn?.getBoundingClientRect()); 7994 break; 7995 } 7996 case 'signed-reply': _sendSignedReply(docId); break; 7997 case 'close': closeTab(docId); break; 7998 case 'delete': deleteActiveDocument(); break; 7999 } 8000 }); 8001 }); 8002 } 8003 8004 /** 8005 * "Send signed reply" â flatten the current PDF (form fields + signature 8006 * stamps + freeform annotations), drop it into the compose-uploads dir, 8007 * then either: 8008 * 1. add the attachment to an existing open email draft for the same 8009 * source thread (so multiple signed docs accumulate into one reply), or 8010 * 2. create a fresh email-language draft document pre-filled with To / 8011 * Subject / In-Reply-To / References and the first attachment. 8012 * Switches the doc panel to that draft so the user can review + send. 8013 */ 8014 async function _sendSignedReply(docId) { 8015 const doc = docs.get(docId); 8016 if (!doc || !doc.sourceEmailUid) return; 8017 if (uiModule) uiModule.showToast('Preparing signed replyâ¦'); 8018 let result; 8019 try { 8020 const res = await fetch(`${API_BASE}
8020/api/document/${encodeURIComponent(docId)}/prepare-signed-reply`, { 8021 method: 'POST', 8022 credentials: 'same-origin', 8023 }); 8024 result = await res.json().catch(() => ({})); 8025 if (!res.ok || !result.ok) { 8026 const msg = (result && result.error) || `HTTP ${res.status}`; 8027 if (uiModule) uiModule.showError(`Couldn't prepare signed reply: ${msg}`); 8028 return; 8029 } 8030 } catch (e) { 8031 console.error('prepare-signed-reply failed:', e); 8032 if (uiModule) uiModule.showError("Couldn't prepare signed reply"); 8033 return; 8034 } 8035 8036 const att = result.attachment; 8037 const reply = result.reply || {}; 8038 const mid = reply.source_message_id || doc.sourceEmailMessageId || ''; 8039 8040 // 1) Already have a draft tab open for this source thread? Append. 8041 for (const [, d] of docs) { 8042 if (d.language === 'email' && d._draftForMessageId === mid && mid) { 8043 d._composeAtts = (d._composeAtts || []).concat([att]); 8044 await loadDocument(d.id); 8045 _renderComposeAttachments(); 8046 if (uiModule) uiModule.showToast(`Added "${att.filename}" to the reply draft`); 8047 return; 8048 } 8049 } 8050 8051 // 2) Otherwise create a fresh email draft. 8052 const headerLines = [ 8053 `To: ${reply.to || ''}`, 8054 `Subject: ${reply.subject || ''}`, 8055 reply.in_reply_to ? `In-Reply-To: ${reply.in_reply_to}` : null, 8056 reply.references ? `References: ${reply.references}` : null, 8057 reply.source_uid ? `X-Source-UID: ${reply.source_uid}` : null, 8058 reply.source_folder ? `X-Source-Folder: ${reply.source_folder}` : null, 8059 ].filter(Boolean); 8060 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'; 8061 8062 let draftId = null; 8063 try { 8064 // Use the source PDF's session if available; else fall back to current. 8065 let sessionId = doc.sessionId 8066 || _lastSessionId 8067 || (sessionModule && sessionModule.getCurrentSessionId()); 8068 if (!sessionId) { 8069 try { sessionId = await _autoCreateSession(); } catch (_) {} 8070 } 8071 const cRes = await fetch(`${API_BASE}/api/document`, { 8072 method: 'POST', 8073 headers: { 'Content-Type': 'application/json' }, 8074 credentials: 'same-origin', 8075 body: JSON.stringify({ 8076 session_id: sessionId, 8077 title: reply.subject || 'Signed reply', 8078 language: 'email', 8079 content, 8080 }), 8081 }); 8082 const created = await cRes.json(); 8083 draftId = created && (created.id || created.doc_id); 8084 if (!draftId) throw new Error('No draft id returned'); 8085 } catch (e) { 8086 console.error('Failed to create draft doc:', e); 8087 if (uiModule) uiModule.showError("Couldn't create reply draft"); 8088 return; 8089 } 8090 8091 // Tag the draft (in-memory only) with the thread message-id so future 8092 // signed PDFs from the same email get appended to this same draft. 8093 addDocToTabs({ 8094 id: draftId, 8095 title: reply.subject || 'Signed reply', 8096 language: 'email', 8097 current_content: content, 8098 version_count: 1, 8099 }, doc.sessionId); 8100 const draft = docs.get(draftId); 8101 if (draft) { 8102 draft._composeAtts = [att]; 8103 draft._draftForMessageId = mid; 8104 if (reply.account_id) draft._draftAccountId = reply.account_id; 8105 } 8106 8107 await loadDocument(draftId); 8108 _renderComposeAttachments(); 8109 if (uiModule) uiModule.showToast(`Reply draft ready â "${att.filename}" attached`); 8110 } 8111 8112 /** Save manual edits */ 8113 export async function saveDocument({ silent = false } = {}) { 8114 if (!activeDocId) return; 8115 const textarea = document.getElementById('doc-editor-textarea'); 8116 if (!textarea) return; 8117 8118 try { 8119 const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, { 8120 method: 'PUT', 8121 headers: { 'Content-Type': 'application/json' }, 8122 body: JSON.stringify({ content: textarea.value }), 8123 }); 8124 const doc = await res.json(); 8125 const badge = document.getElementById('doc-version-badge'); 8126 if (badge) { const _v = doc.version_count || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; } 8127 // Update map 8128 if (docs.has(activeDocId)) { 8129 docs.get(activeDocId).version = doc.version_count || 1; 8130 docs.get(activeDocId).content = textarea.value; 8131 } 8132 _syncDocIndicator();
8133 if (!silent && uiModule) uiModule.showToast('Document saved'); 8134 } catch (e) { 8135 console.error('Failed to save document:', e); 8136 if (!silent && uiModule) uiModule.showError('Failed to save document'); 8137 } 8138 } 8139 8140 /** Export/download the active document */ 8141 let _docxReady = null; 8142 function ensureDocx() { 8143 if (_docxReady) return _docxReady; 8144 if (window.docx) return (_docxReady = Promise.resolve()); 8145 _docxReady = new Promise((resolve, reject) => { 8146 const s = document.createElement('script'); 8147 s.src = '/static/lib/docx.umd.min.js'; 8148 s.onload = resolve; 8149 s.onerror = () => reject(new Error('Failed to load DOCX library')); 8150 document.head.appendChild(s); 8151 }); 8152 return _docxReady; 8153 } 8154 8155 let _html2pdfReady = null; 8156 function ensureHtml2Pdf() { 8157 if (_html2pdfReady) return _html2pdfReady; 8158 if (window.html2pdf) return (_html2pdfReady = Promise.resolve()); 8159 _html2pdfReady = new Promise((resolve, reject) => { 8160 const s = document.createElement('script'); 8161 s.src = '/static/lib/html2pdf.bundle.min.js'; 8162 s.onload = resolve; 8163 s.onerror = () => reject(new Error('Failed to load PDF library')); 8164 document.head.appendChild(s); 8165 }); 8166 return _html2pdfReady; 8167 } 8168 8169 function _getExportBaseName() { 8170 const doc = docs.get(activeDocId); 8171 const title = (doc && doc.title) || 'document'; 8172 const safeName = title.replace(/[^a-zA-Z0-9_\-. ]/g, '_').trim() || 'document'; 8173 const ver = doc && doc.version ? `_v${doc.version}` : ''; 8174 return safeName + ver; 8175 } 8176 8177 function exportDocument() { 8178 if (!activeDocId) return; 8179 const textarea = document.getElementById('doc-editor-textarea'); 8180 if (!textarea) return; 8181 const doc = docs.get(activeDocId); 8182 const title = (doc && doc.title) || 'document'; 8183 const lang = document.getElementById('doc-language-select')?.value || ''; 8184 const extMap = { 8185 javascript: '.js', python: '.py', html: '.html', css: '.css', 8186 markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', 8187 sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', csharp: '.cs', 8188 typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt', 8189 xml: '.xml', toml: '.toml', ini: '.ini', csv: '.csv', 8190 }; 8191 const ext = extMap[lang] || '.txt'; 8192 const safeName = title.replace(/[^a-zA-Z0-9_\-. ]/g, '_').trim() || 'document'; 8193 const ver = doc && doc.version ? `_v${doc.version}` : ''; 8194 const mime = lang === 'csv' ? 'text/csv' : lang === 'json' ? 'application/json' : 'text/plain'; 8195 const blob = new Blob([textarea.value], { type: mime }); 8196 const a = document.createElement('a'); 8197 a.href = URL.createObjectURL(blob); 8198 a.download = safeName + ver + ext; 8199 a.click(); 8200 URL.revokeObjectURL(a.href); 8201 } 8202 8203 // "Import from device" â open a file picker, upload, and immediately open 8204 // the resulting doc in THIS panel (vs. dumping it in the library and 8205 // making the user click through). Mirrors the library's extension logic 8206 // for text/code; routes PDFs through the dedicated import-pdf endpoint 8207 // that handles AcroForm fields. Spreadsheets fall back to the library 8208 // flow which already knows how to split sheets. 8209 function _importFromDevice() { 8210 const EXT_TO_LANG = { 8211 '.py':'python','.js':'javascript','.ts':'typescript','.html':'html','.htm':'html', 8212 '.css':'css','.md':'markdown','.json':'json','.yml':'yaml','.yaml':'yaml', 8213 '.sh':'bash','.bash':'bash','.sql':'sql','.rs':'rust','.go':'go', 8214 '.java':'java','.c':'c','.cpp':'cpp','.h':'c','.hpp':'cpp', 8215 '.rb':'ruby','.php':'php','.xml':'xml','.toml':'toml','.ini':'ini', 8216 '.txt':'','.log':'','.csv':'csv','.tsv':'csv','.jsx':'javascript','.tsx':'typescript', 8217 }; 8218 const fi = document.createElement('input'); 8219 fi.type = 'file'; 8220 fi.style.display = 'none'; 8221 fi.addEventListener('change', async () => { 8222 const file = fi.files?.[0]; 8223 if (!file) return; 8224 const name = file.name; 8225 const dotIdx = name.lastIndexOf('.'); 8226 const ext = dotIdx >= 0 ? name.slice(dotIdx).toLowerCase() : ''; 8227 const baseTitle = dotIdx > 0 ? name.slice(0, dotIdx) : name; 8228 const isSpreadsheet = ['.xlsx','.xls','.ods'].includes(ext); 8229 const isPdf = ext === '.pdf'; 8230 // Spreadsheets need the library's per-sheet split â defer to it. 8231 if (isSpreadsheet) { 8232 openLibrary(); 8233 requestAnimationFrame(() => requestAnimationFrame(() => document.getElementById('doclib-import-file-btn')?.click())); 8234 return; 8235 }
8236 try { 8237 let docId = null; 8238 if (isPdf) { 8239 const fd = new FormData(); 8240 fd.append('file', file); 8241 const sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()) || _lastSessionId || ''; 8242 if (sid) fd.append('session_id', sid); 8243 const r = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' }); 8244 if (!r.ok) throw new Error('PDF import failed'); 8245 const j = await r.json(); 8246 docId = j.doc_id || j.id; 8247 } else { 8248 const content = await new Promise((res, rej) => { 8249 const reader = new FileReader(); 8250 reader.onload = () => res(reader.result || ''); 8251 reader.onerror = () => rej(reader.error); 8252 reader.readAsText(file); 8253 }); 8254 const lang = EXT_TO_LANG[ext] !== undefined ? EXT_TO_LANG[ext] : null; 8255 const sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()) || _lastSessionId || ''; 8256 const body = { title: baseTitle, language: lang, content }; 8257 if (sid) body.session_id = sid; 8258 const r = await fetch(`${API_BASE}/api/document`, { 8259 method: 'POST', 8260 headers: { 'Content-Type': 'application/json' }, 8261 credentials: 'same-origin', 8262 body: JSON.stringify(body), 8263 }); 8264 if (!r.ok) throw new Error('Import failed'); 8265 const j = await r.json(); 8266 docId = j.id || j.doc_id; 8267 } 8268 if (docId) { 8269 // Fetch the full doc so addDocToTabs has the proper content + 8270 // language fields (it's used downstream by switchToDoc). 8271 try { 8272 const dr = await fetch(`${API_BASE}/api/document/${docId}`, { credentials: 'same-origin' }); 8273 const full = dr.ok ? await dr.json() : { id: docId, title: baseTitle }; 8274 const sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()) || _lastSessionId || ''; 8275 addDocToTabs(full, full.session_id || sid); 8276 switchToDoc(full.id || docId); 8277 } catch (_) { 8278 // Fallback â at least try to switch (may fail silently if not loaded). 8279 addDocToTabs({ id: docId, title: baseTitle }, _lastSessionId || ''); 8280 switchToDoc(docId); 8281 } 8282 } 8283 } catch (err) { 8284 if (uiModule && uiModule.showError) uiModule.showError('Import failed: ' + (err.message || err)); 8285 } finally { 8286 fi.value = ''; 8287 fi.remove(); 8288 } 8289 }); 8290 document.body.appendChild(fi); 8291 fi.click(); 8292 } 8293 8294 function showExportMenu(e, anchorRect) { 8295 if (e) e.stopPropagation(); 8296 // Remove existing menu if any 8297 const existing = document.getElementById('doc-export-menu'); 8298 if (existing) { existing.remove(); return; } 8299 8300 // Position from provided rect, clicked element, or fallback to language select 8301 const rect = anchorRect 8302 || (e && e.target && e.target.closest('button')?.getBoundingClientRect()) 8303 || document.getElementById('doc-language-select')?.getBoundingClientRect(); 8304 if (!rect) return; 8305 8306 const lang = document.getElementById('doc-language-select')?.value || ''; 8307 const extMap = { 8308 javascript: '.js', python: '.py', html: '.html', css: '.css', 8309 markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', 8310 sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', csharp: '.cs', 8311 typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt', 8312 xml: '.xml', toml: '.toml', ini: '.ini', csv: '.csv', 8313 }; 8314 const ext = extMap[lang] || '.txt'; 8315 8316 const menu = document.createElement('div'); 8317 menu.id = 'doc-export-menu'; 8318 menu.className = 'doc-overflow-menu open'; 8319 menu.style.position = 'fixed'; 8320 menu.style.top = (rect.bottom + 2) + 'px'; 8321 menu.style.right = (window.innerWidth - rect.right) + 'px'; 8322 menu.style.left = 'auto'; 8323 menu.style.zIndex = '9999'; 8324 8325 const langLabel = lang ? lang.toUpperCase() : 'TXT'; 8326 // Form-backed markdown doc â primary export is the filled PDF, not the 8327 // markdown source. Promote it to the top of the menu. 8328 const liveContent = document.getElementById('doc-editor-textarea')?.value 8329 || docs.get(activeDocId)?.content || ''; 8330 const isForm = _isFormBackedDoc(liveContent); 8331 const options = []; 8332 // Import lives at the top of the same dropdown â it's a sibling action 8333 // ("bring something IN" vs "send something OUT"), and the footer was 8334 // getting too cramped for dedicated icons. 8335 options.push({ label: 'Import from library', fn: () => openLibrary() }); 8336 options.push({ label: 'Import from device', fn: () => _importFromDevice(), _divider: true }); 8337 if (isForm) options.push({ label: 'Filled PDF (.pdf)', fn: _downloadFilledPdf }); 8338 options.push( 8339 { label: 'Export Markdown', fn: exportDocument }, 8340 { label: 'Print as PDF', fn: exportAsPdf }, 8341 { label: 'Export as Word', fn: exportAsDocx }, 8342 ); 8343 8344 options.forEach(opt => { 8345 const item = document.createElement('button'); 8346 item.className = 'doc-overflow-item'; 8347 item.textContent = opt.label; 8348 item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); }); 8349 menu.appendChild(item); 8350 if (opt._divider) { 8351 const sep = document.createElement('div'); 8352 sep.className = 'doc-overflow-divider'; 8353 sep.style.cssText = 'height:1px;margin:3px 6px;background:color-mix(in srgb,var(--border) 60%,transparent);'; 8354 menu.appendChild(sep); 8355 } 8356 }); 8357 8358 document.body.appendChild(menu);
8359 // Flip above the anchor when there's no room below â the Export button now 8360 // lives in the bottom footer, so the menu would otherwise drop off-screen. 8361 const mh = menu.offsetHeight; 8362 if (rect.bottom + mh > window.innerHeight - 8) { 8363 menu.style.top = 'auto'; 8364 menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; 8365 } 8366 const close = (ev) => { 8367 if (ev && ev.type === 'keydown') { 8368 if (ev.key !== 'Escape') return; 8369 ev.preventDefault(); 8370 ev.stopPropagation(); 8371 ev.stopImmediatePropagation?.(); 8372 } else if (ev && menu.contains(ev.target)) { 8373 return; 8374 } 8375 menu.remove(); 8376 document.removeEventListener('click', close); 8377 document.removeEventListener('keydown', close, true); 8378 }; 8379 setTimeout(() => document.addEventListener('click', close), 100); 8380 document.addEventListener('keydown', close, true); 8381 } 8382 8383 function exportAsHtml() { 8384 if (!activeDocId) return; 8385 const textarea = document.getElementById('doc-editor-textarea'); 8386 if (!textarea) return; 8387 const lang = document.getElementById('doc-language-select')?.value || ''; 8388 const text = textarea.value || ''; 8389 let body; 8390 if (lang === 'markdown' && markdownModule?.mdToHtml) { 8391 body = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal 8392 } else { 8393 body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' + 8394 text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>'; 8395 } 8396 const title = docs.get(activeDocId)?.title || 'document'; 8397 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>`; 8398 const blob = new Blob([html], { type: 'text/html' }); 8399 const a = document.createElement('a'); 8400 a.href = URL.createObjectURL(blob); 8401 a.download = _getExportBaseName() + '.html'; 8402 a.click(); 8403 URL.revokeObjectURL(a.href); 8404 if (uiModule) uiModule.showToast('Exported as HTML'); 8405 } 8406 8407 async function exportAsPdf() { 8408 if (!activeDocId) return; 8409 const textarea = document.getElementById('doc-editor-textarea'); 8410 if (!textarea) return; 8411 try { 8412 await ensureHtml2Pdf(); 8413 } catch (e) { 8414 if (uiModule) uiModule.showError('Failed to load PDF library'); 8415 return; 8416 } 8417 const lang = document.getElementById('doc-language-select')?.value || ''; 8418 const text = textarea.value || ''; 8419 // Render content as HTML for PDF 8420 let html; 8421 if (lang === 'markdown' && markdownModule?.mdToHtml) { 8422 html = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal 8423 } else { 8424 html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' + 8425 text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>'; 8426 } 8427 const container = document.createElement('div'); 8428 container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;'; 8429 container.innerHTML = html; 8430 const baseName = _getExportBaseName(); 8431 window.html2pdf().set({ 8432 margin: 10, 8433 filename: baseName + '.pdf', 8434 image: { type: 'jpeg', quality: 0.95 }, 8435 html2canvas: { scale: 2 }, 8436 jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, 8437 }).from(container).save(); 8438 if (uiModule) uiModule.showToast('Exporting PDF...'); 8439 } 8440 8441 async function exportAsDocx() { 8442 if (!activeDocId) return; 8443 const textarea = document.getElementById('doc-editor-textarea'); 8444 if (!textarea) return; 8445 try { 8446 await ensureDocx(); 8447 } catch (e) { 8448 if (uiModule) uiModule.showError('Failed to load DOCX library'); 8449 return; 8450 } 8451 const text = textarea.value || ''; 8452 const { Document, Packer, Paragraph, TextRun, HeadingLevel } = window.docx; 8453 // Parse text into paragraphs, handle markdown headings 8454 const paragraphs = text.split('\n').map(line => { 8455 const h1 = line.match(/^# (.+)/); 8456 const h2 = line.match(/^## (.+)/); 8457 const h3 = line.match(/^### (.+)/); 8458 if (h1) return new Paragraph({ text: h1[1], heading: HeadingLevel.HEADING_1 }); 8459 if (h2) return new Paragraph({ text: h2[1], heading: HeadingLevel.HEADING_2 }); 8460 if (h3) return new Paragraph({ text: h3[1], heading: HeadingLevel.HEADING_3 }); 8461 // Handle bold/italic 8462 const runs = []; 8463 const parts = line.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/); 8464 for (const part of parts) { 8465 if (part.startsWith('**') && part.endsWith('**')) { 8466 runs.push(new TextRun({ text: part.slice(2, -2), bold: true })); 8467 } else if (part.startsWith('*') && part.endsWith('*')) { 8468 runs.push(new TextRun({ text: part.slice(1, -1), italics: true })); 8469 } else { 8470 runs.push(new TextRun(part)); 8471 } 8472 } 8473 return new Paragraph({ children: runs }); 8474 }); 8475 8476 const doc = new Document({ 8477 sections: [{ children: paragraphs }], 8478 }); 8479 const blob = await Packer.toBlob(doc); 8480 const baseName = _getExportBaseName(); 8481 const a = document.createElement('a'); 8482 a.href = URL.createObjectURL(blob); 8483 a.download = baseName + '.docx'; 8484 a.click(); 8485 URL.revokeObjectURL(a.href); 8486 if (uiModule) uiModule.showToast('Exported as DOCX'); 8487 } 8488 8489 /** Delete the active document */ 8490 async function deleteActiveDocument() { 8491 if (!activeDocId) return; 8492 const doc = docs.get(activeDocId); 8493 const name = doc ? doc.title : 'this document';
8494 const ok = uiModule && uiModule.styledConfirm 8495 ? await uiModule.styledConfirm(`Delete "${name}"?`, { confirmText: 'Delete', danger: true }) 8496 : confirm(`Delete "${name}"?`); 8497 if (!ok) return; 8498 try { 8499 const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, { method: 'DELETE' }); 8500 if (!res.ok) throw new Error('Delete failed'); 8501 // Remove tab 8502 const tab = document.querySelector(`.doc-tab[data-doc-id="${activeDocId}"]`); 8503 if (tab) tab.remove(); 8504 docs.delete(activeDocId); 8505 // Switch to another doc or close panel 8506 const remaining = Array.from(docs.keys()); 8507 if (remaining.length > 0) { 8508 switchToDoc(remaining[0]); 8509 } else { 8510 activeDocId = null; 8511 closePanel(); 8512 } 8513 if (uiModule) uiModule.showToast('Document deleted'); 8514 } catch (e) { 8515 console.error('Failed to delete document:', e); 8516 if (uiModule) uiModule.showError('Failed to delete document'); 8517 } 8518 } 8519 8520 /** Toggle fullscreen on doc editor pane */ 8521 function toggleFullscreen() { 8522 const pane = document.getElementById('doc-editor-pane'); 8523 const container = document.getElementById('chat-container'); 8524 if (!pane) return; 8525 // Note: the divider stays in the DOM during fullscreen so its chevron can 8526 // act as the exit-fullscreen affordance (the CSS rule 8527 // `body:has(.doc-editor-pane.doc-fullscreen) .doc-divider-collapse` slides 8528 // it into a forced-inside position). Hiding the divider here would hide 8529 // the chevron with it. 8530 if (pane.classList.contains('doc-fullscreen')) { 8531 pane.classList.remove('doc-fullscreen'); 8532 if (container) container.style.display = ''; 8533 } else { 8534 pane.classList.add('doc-fullscreen'); 8535 if (container) container.style.display = 'none'; 8536 } 8537 // Re-check md toolbar overflow after layout change 8538 const mdToolbar = document.getElementById('doc-md-toolbar'); 8539 if (mdToolbar?._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow); 8540 } 8541 8542 /** Toggle markdown preview */ 8543 function _setMarkdownPreviewActive(active, { remember = true } = {}) { 8544 const preview = document.getElementById('doc-md-preview'); 8545 const wrap = document.getElementById('doc-editor-wrap'); 8546 const textarea = document.getElementById('doc-editor-textarea'); 8547 if (!preview || !wrap || !textarea) return; 8548 8549 if (active) { 8550 const md = textarea.value || ''; 8551 if (markdownModule && markdownModule.mdToHtml) { 8552 preview.innerHTML = markdownModule.mdToHtml(md, { shortcodes: false }); // doc preview: keep :shortcodes: literal 8553 } else { 8554 preview.innerHTML = md.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g, '<br>'); 8555 } 8556 if (window.hljs) { 8557 preview.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b)); 8558 } 8559 if (markdownModule && markdownModule.renderMermaid) { 8560 markdownModule.renderMermaid(preview); 8561 } 8562 preview.style.display = ''; 8563 wrap.style.display = 'none'; 8564 } else { 8565 preview.style.display = 'none'; 8566 preview.innerHTML = ''; 8567 const isEmailDoc = docs.get(activeDocId)?.language === 'email'; 8568 const richEmailBody = document.getElementById('doc-email-richbody'); 8569 if (!(isEmailDoc && richEmailBody && richEmailBody.style.display !== 'none')) { 8570 wrap.style.display = ''; 8571 } 8572 } 8573 if (remember && activeDocId && docs.has(activeDocId)) { 8574 docs.get(activeDocId)._markdownPreviewActive = !!active; 8575 } 8576 _syncHeaderActions(); 8577 } 8578 8579 function toggleMarkdownPreview() { 8580 const preview = document.getElementById('doc-md-preview'); 8581 _setMarkdownPreviewActive(!(preview && preview.style.display !== 'none')); 8582 } 8583 8584 /** Parse CSV text into a 2D array (handles quoted fields) */ 8585 function parseCSV(text) { 8586 const rows = []; 8587 let row = []; 8588 let field = ''; 8589 let inQuotes = false; 8590 for (let i = 0; i < text.length; i++) { 8591 const ch = text[i]; 8592 if (inQuotes) { 8593 if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; } 8594 else if (ch === '"') { inQuotes = false; } 8595 else { field += ch; } 8596 } else { 8597 if (ch === '"') { inQuotes = true; } 8598 else if (ch === ',') { row.push(field); field = ''; }
8599 else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) { 8600 if (ch === '\r') i++; 8601 row.push(field); field = ''; 8602 if (row.some(c => c.trim())) rows.push(row); 8603 row = []; 8604 } else { field += ch; } 8605 } 8606 } 8607 row.push(field); 8608 if (row.some(c => c.trim())) rows.push(row); 8609 return rows; 8610 } 8611 8612 /** Escape a CSV field (quote if it contains comma, quote, or newline) */ 8613 function csvEscapeField(val) { 8614 if (val.includes(',') || val.includes('"') || val.includes('\n')) { 8615 return '"' + val.replace(/"/g, '""') + '"'; 8616 } 8617 return val; 8618 } 8619 8620 /** Rebuild CSV text from the live table DOM */ 8621 function syncTableToTextarea(preview, textarea) { 8622 const table = preview.querySelector('.csv-table'); 8623 if (!table) return; 8624 const lines = []; 8625 // Header 8626 const ths = table.querySelectorAll('thead th'); 8627 if (ths.length) lines.push([...ths].map(th => csvEscapeField(th.textContent)).join(',')); 8628 // Body 8629 table.querySelectorAll('tbody tr').forEach(tr => { 8630 const cells = [...tr.querySelectorAll('td')].map(td => csvEscapeField(td.textContent)); 8631 lines.push(cells.join(',')); 8632 }); 8633 textarea.value = lines.join('\n') + '\n'; 8634 textarea.dispatchEvent(new Event('input', { bubbles: true })); 8635 } 8636 8637 /** Toggle CSV table preview */ 8638 function toggleCsvPreview() { 8639 const preview = document.getElementById('doc-csv-preview'); 8640 const wrap = document.getElementById('doc-editor-wrap'); 8641 const textarea = document.getElementById('doc-editor-textarea'); 8642 if (!preview || !wrap || !textarea) return; 8643 8644 if (preview.style.display === 'none') { 8645 const rows = parseCSV(textarea.value || ''); 8646 if (rows.length === 0) { 8647 // Re-route the "no data" message to the shared run-output block so 8648 // every doc type surfaces errors/empty-state in the same place 8649 // (instead of stamping it inside the table view itself). 8650 let outputPanel = document.getElementById('doc-run-output'); 8651 if (!outputPanel) { 8652 outputPanel = document.createElement('div'); 8653 outputPanel.id = 'doc-run-output'; 8654 outputPanel.className = 'doc-run-output'; 8655 const editorWrap = document.getElementById('doc-editor-wrap'); 8656 if (editorWrap) editorWrap.after(outputPanel); 8657 } 8658 outputPanel.style.display = 'block'; 8659 outputPanel.innerHTML = '<pre class="doc-run-error">No data â CSV is empty or unparseable.</pre>'; 8660 return; 8661 } else { 8662 const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 8663 const colCount = Math.max(...rows.map(r => r.length)); 8664 let html = '<div class="csv-table-wrap"><table class="csv-table"><thead><tr>'; 8665 for (let j = 0; j < colCount; j++) { 8666 html += `<th contenteditable="true">${esc(rows[0][j] || '')}</th>`; 8667 } 8668 html += '</tr></thead><tbody>'; 8669 for (let i = 1; i < rows.length; i++) { 8670 html += '<tr>'; 8671 for (let j = 0; j < colCount; j++) { 8672 html += `<td contenteditable="true">${esc(rows[i][j] || '')}</td>`; 8673 } 8674 html += '</tr>'; 8675 } 8676 html += '</tbody></table>'; 8677 html += '</div>'; 8678 preview.innerHTML = html; 8679 8680 // Sync edits back to textarea 8681 const table = preview.querySelector('.csv-table'); 8682 if (table) { 8683 table.addEventListener('input', () => syncTableToTextarea(preview, textarea)); 8684 // Prevent Enter from creating <br> inside cells 8685 table.addEventListener('keydown', (e) => { 8686 if (e.key === 'Enter' && !e.shiftKey) { 8687 e.preventDefault(); 8688 // Move to next row, same column 8689 const cell = e.target.closest('td,th'); 8690 if (!cell) return; 8691 const colIdx = [...cell.parentElement.children].indexOf(cell); 8692 const nextRow = cell.parentElement.nextElementSibling; 8693 if (nextRow && nextRow.children[colIdx]) { 8694 nextRow.children[colIdx].focus(); 8695 } 8696 } else if (e.key === 'Tab') { 8697 e.preventDefault(); 8698 const cell = e.target.closest('td,th'); 8699 if (!cell) return; 8700 const next = e.shiftKey ? cell.previousElementSibling : cell.nextElementSibling; 8701 if (next) next.focus(); 8702 } 8703 }); 8704 } 8705 8706 // Add row button 8707 const addBtn = preview.querySelector('.csv-add-row-btn'); 8708 if (addBtn && table) { 8709 addBtn.addEventListener('click', () => { 8710 const tbody = table.querySelector('tbody'); 8711 const tr = document.createElement('tr'); 8712 for (let j = 0; j < colCount; j++) { 8713 const td = document.createElement('td'); 8714 td.contentEditable = 'true'; 8715 tr.appendChild(td); 8716 } 8717 tbody.appendChild(tr); 8718 tr.children[0].focus(); 8719 syncTableToTextarea(preview, textarea); 8720 }); 8721 } 8722 } 8723 preview.style.display = ''; 8724 wrap.style.display = 'none'; 8725 } else { 8726 preview.style.display = 'none'; 8727 wrap.style.display = ''; 8728 } 8729 // Update the segmented Code/Run toggle's active class so the icon 8730 // highlights match the new state â without this, opening a CSV that 8731 // auto-shows the table view left the Edit (code) side wrongly marked 8732 // active and the user had to flip the toggle to resync. 8733 _syncHeaderActions(); 8734 } 8735 8736 /** Toggle inline HTML preview (iframe) */ 8737 function toggleHtmlPreview() { 8738 const iframe = document.getElementById('doc-html-preview'); 8739 const wrap = document.getElementById('doc-editor-wrap'); 8740 const textarea = document.getElementById('doc-editor-textarea'); 8741 if (!iframe || !wrap || !textarea) return; 8742 8743 if (!_htmlPreviewActive) { 8744 // Show preview â hide markdown preview if active 8745 const mdPreview = document.getElementById('doc-md-preview'); 8746 if (mdPreview) mdPreview.style.display = 'none'; 8747 const code = textarea.value || ''; 8748 iframe.srcdoc = code; 8749 iframe.style.display = ''; 8750 wrap.style.display = 'none'; 8751 _htmlPreviewActive = true; 8752 renderTabs(); 8753 } else { 8754 exitHtmlPreview(); 8755 } 8756 } 8757 8758 /** Exit HTML preview back to code view */ 8759 function exitHtmlPreview() { 8760 const iframe = document.getElementById('doc-html-preview'); 8761 const wrap = document.getElementById('doc-editor-wrap'); 8762 if (!_htmlPreviewActive) return; 8763 _htmlPreviewActive = false;
8764 if (iframe) { iframe.style.display = 'none'; iframe.srcdoc = ''; } 8765 if (wrap) wrap.style.display = ''; 8766 renderTabs(); 8767 } 8768 8769 // ---- Streaming animation engine ---- 8770 8771 /** 8772 * Simple diff: find the first and last differing positions between two strings. 8773 * Returns { prefixLen, oldMid, newMid } where: 8774 * oldText = prefix + oldMid + suffix 8775 * newText = prefix + newMid + suffix 8776 */ 8777 function simpleDiff(oldText, newText) { 8778 let i = 0; 8779 const minLen = Math.min(oldText.length, newText.length); 8780 while (i < minLen && oldText[i] === newText[i]) i++; 8781 const prefixLen = i; 8782 8783 let oj = oldText.length; 8784 let nj = newText.length; 8785 while (oj > prefixLen && nj > prefixLen && oldText[oj - 1] === newText[nj - 1]) { 8786 oj--; nj--; 8787 } 8788 8789 return { 8790 prefixLen, 8791 oldMid: oldText.slice(prefixLen, oj), 8792 newMid: newText.slice(prefixLen, nj), 8793 }; 8794 } 8795 8796 /** 8797 * Animate the transition from oldText to newText in the editor textarea. 8798 * First deletes the old differing section char-by-char, then types the new one. 8799 */ 8800 /** 8801 * Compute a line-level diff between two texts. 8802 * Returns array of { type: 'same'|'del'|'add', text: string } 8803 */ 8804 function lineDiff(oldText, newText) { 8805 const oldLines = oldText.split('\n'); 8806 const newLines = newText.split('\n'); 8807 8808 // Simple LCS-based diff (Myers-like, but O(n*m) for clarity) 8809 const m = oldLines.length, n = newLines.length; 8810 // For very large diffs, skip detailed diff 8811 if (m * n > 500000) return null; 8812 8813 const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1)); 8814 for (let i = m - 1; i >= 0; i--) { 8815 for (let j = n - 1; j >= 0; j--) { 8816 if (oldLines[i] === newLines[j]) { 8817 dp[i][j] = dp[i + 1][j + 1] + 1; 8818 } else { 8819 dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); 8820 } 8821 } 8822 } 8823 8824 const result = []; 8825 let i = 0, j = 0; 8826 while (i < m || j < n) { 8827 if (i < m && j < n && oldLines[i] === newLines[j]) { 8828 result.push({ type: 'same', text: oldLines[i] }); 8829 i++; j++; 8830 } else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) { 8831 result.push({ type: 'add', text: newLines[j] }); 8832 j++; 8833 } else { 8834 result.push({ type: 'del', text: oldLines[i] }); 8835 i++; 8836 } 8837 } 8838 return result; 8839 } 8840 8841 async function animateDocChange(oldText, newText) { 8842 if (_animationCancel) _animationCancel(); 8843 8844 const textarea = document.getElementById('doc-editor-textarea'); 8845 const wrap = document.getElementById('doc-editor-wrap'); 8846 if (!textarea) return false; 8847 if (oldText === newText) return true; 8848 8849 const diff = lineDiff(oldText, newText); 8850 if (!diff) return false; // too large for diff 8851 8852 // Count changes 8853 const delCount = diff.filter(d => d.type === 'del').length; 8854 const addCount = diff.filter(d => d.type === 'add').length; 8855 if (delCount + addCount === 0) return true; 8856 8857 _animationInProgress = true; 8858 let cancelled = false; 8859 _animationCancel = () => { cancelled = true; }; 8860 8861 textarea.readOnly = true; 8862 if (wrap) wrap.classList.add('animating'); 8863 8864 try { 8865 // Build diff overlay HTML 8866 const overlay = document.createElement('div'); 8867 overlay.className = 'doc-diff-overlay'; 8868 8869 // Stats bar 8870 const stats = document.createElement('div'); 8871 stats.className = 'doc-diff-stats'; 8872 stats.innerHTML = `<span class="diff-stat-del">\u2212${delCount}</span> <span class="diff-stat-add">+${addCount}</span>`; 8873 overlay.appendChild(stats); 8874 8875 const content = document.createElement('div'); 8876 content.className = 'doc-diff-content'; 8877 8878 // Render diff lines â show context around changes 8879 let inContext = false; 8880 let skipped = 0; 8881 diff.forEach((line, idx) => { 8882 if (line.type === 'same') { 8883 // Show 2 lines of context around changes 8884 const nearChange = diff.slice(Math.max(0, idx - 2), idx + 3).some(d => d.type !== 'same'); 8885 if (nearChange) { 8886 if (skipped > 0) { 8887 const sep = document.createElement('div'); 8888 sep.className = 'doc-diff-sep'; 8889 sep.textContent = `\u22EF ${skipped} unchanged`; 8890 content.appendChild(sep); 8891 skipped = 0; 8892 }
8893 const row = document.createElement('div'); 8894 row.className = 'doc-diff-line same'; 8895 row.textContent = line.text || '\u00A0'; 8896 content.appendChild(row); 8897 } else { 8898 skipped++; 8899 } 8900 } else { 8901 if (skipped > 0) { 8902 const sep = document.createElement('div'); 8903 sep.className = 'doc-diff-sep'; 8904 sep.textContent = `\u22EF ${skipped} unchanged`; 8905 content.appendChild(sep); 8906 skipped = 0; 8907 } 8908 const row = document.createElement('div'); 8909 row.className = 'doc-diff-line ' + line.type; 8910 row.textContent = (line.type === 'del' ? '\u2212 ' : '+ ') + (line.text || '\u00A0'); 8911 content.appendChild(row); 8912 } 8913 }); 8914 8915 overlay.appendChild(content); 8916 8917 // Insert overlay over the textarea 8918 const editorArea = textarea.parentElement; 8919 if (editorArea) editorArea.appendChild(overlay); 8920 8921 // Show diff for a moment, then fade to final content 8922 overlay.offsetHeight; // force reflow 8923 overlay.classList.add('visible'); 8924 8925 const DIFF_DISPLAY_MS = 2500; 8926 await new Promise(r => setTimeout(r, cancelled ? 0 : DIFF_DISPLAY_MS)); 8927 8928 if (!cancelled) { 8929 overlay.classList.remove('visible'); 8930 overlay.classList.add('fading'); 8931 textarea.value = newText; 8932 syncHighlighting(); 8933 await new Promise(r => setTimeout(r, 400)); 8934 } 8935 8936 overlay.remove(); 8937 8938 if (!cancelled) { 8939 textarea.value = newText; 8940 syncHighlighting(); 8941 } 8942 8943 return !cancelled; 8944 } finally { 8945 textarea.readOnly = false; 8946 _animationInProgress = false; 8947 _animationCancel = null; 8948 if (wrap) wrap.classList.remove('animating'); 8949 } 8950 } 8951 8952 // --- Streaming helpers: open panel & feed content as AI generates --- 8953 let _streamDocId = null; 8954 8955 /** Sync the markdown toolbar + header actions for a streaming doc, so the 8956 * Edit/Preview toggle and formatting tools appear without a manual refresh. */ 8957 function _syncStreamDocChrome(doc) { 8958 if (!doc) return; 8959 const lang = (doc.language || 'markdown').toLowerCase(); 8960 const isMd = lang === 'markdown'; 8961 const isPdf = _isFormBackedDoc(doc.content || ''); 8962 // Show the toolbar for any doc type that has a view toggle of its own 8963 // (markdown editâpreview, or codeârun for renderable code types). The 8964 // `data-mode` attribute lets CSS hide markdown-only buttons (bold, 8965 // italic, headings, etc.) when we're in a code-mode doc. 8966 const renderable = ['svg', 'html', 'css', 'csv', 'python', 'javascript', 'typescript', 8967 'json', 'xml', 'bash', 'sh', 'yaml', 'toml', 'sql']; 8968 const isCodeRenderable = renderable.includes(lang); 8969 const mt = document.getElementById('doc-md-toolbar'); 8970 if (mt) { 8971 const showToolbar = isMd || isPdf || isCodeRenderable; 8972 mt.style.display = showToolbar ? '' : 'none'; 8973 mt.dataset.mode = isMd ? 'md' : (isPdf ? 'pdf' : (isCodeRenderable ? 'code' : '')); 8974 if (showToolbar && mt._syncOverflow) requestAnimationFrame(mt._syncOverflow); 8975 } 8976 _syncHeaderActions(); 8977 } 8978 8979 /** Open the document panel immediately for a doc being streamed in */ 8980 export function streamDocOpen(title, language) { 8981 // If already streaming a doc, reuse it (don't create a second temp doc) 8982 if (_streamDocId && docs.has(_streamDocId)) { 8983 const existing = docs.get(_streamDocId); 8984 if (title) existing.title = title; 8985 if (language) existing.language = language; 8986 // Update UI fields 8987 const titleInput = document.getElementById('doc-title-input'); 8988 const langSelect = document.getElementById('doc-language-select'); 8989 if (title && titleInput) titleInput.value = title; 8990 if (langSelect) langSelect.value = existing.language || 'markdown'; 8991 if (language === 'email') { 8992 _showEmailFields(existing); 8993 } 8994 _syncStreamDocChrome(existing); 8995 renderTabs(); 8996 return; 8997 } 8998 8999 const sessionId = sessionModule?.getCurrentSessionId() || ''; 9000 // Reuse existing doc with same title in this session, or create a temp one 9001 let docId = null; 9002 if (title) { 9003 for (const [existingId, existingDoc] of docs) { 9004 if (existingDoc.title === title && existingDoc.sessionId === sessionId) { 9005 docId = existingId; 9006 break; 9007 } 9008 } 9009 } 9010 if (!docId) { 9011 docId = '_streaming_' + Date.now(); 9012 docs.set(docId, { 9013 id: docId, 9014 title: title || '', 9015 language: language || '', 9016 content: '', 9017 version: 1, 9018 sessionId, 9019 }); 9020 } 9021 _streamDocId = docId; 9022 activeDocId = docId; 9023 _syncDocIndicator(); 9024
9025 if (!isOpen) openPanel(); 9026 9027 // Force doc button visible 9028 const toggleBtn = document.getElementById('overflow-doc-btn'); 9029 if (toggleBtn) { 9030 toggleBtn.style.display = ''; 9031 toggleBtn.classList.remove('toolbar-collapsed'); 9032 toggleBtn.classList.add('has-docs'); 9033 } 9034 const docInd2 = document.getElementById('doc-indicator-btn'); 9035 if (docInd2) docInd2.classList.add('visible'); 9036 9037 const titleInput = document.getElementById('doc-title-input'); 9038 const langSelect = document.getElementById('doc-language-select'); 9039 const badge = document.getElementById('doc-version-badge'); 9040 if (titleInput) titleInput.value = title || ''; 9041 if (langSelect) langSelect.value = language || 'markdown'; 9042 if (badge) badge.textContent = 'v1'; 9043 9044 const textarea = document.getElementById('doc-editor-textarea'); 9045 if (textarea) { 9046 textarea.disabled = false; 9047 textarea.placeholder = 'Document content...'; 9048 textarea.value = ''; 9049 } 9050 // Show streaming indicator 9051 const indicator = document.getElementById('doc-stream-indicator'); 9052 if (indicator) indicator.style.display = ''; 9053 9054 // Show email fields immediately when streaming an email doc so the user 9055 // doesn't have to refresh for the editor to flip into email mode. 9056 if (language === 'email') { 9057 const streamDoc = docs.get(_streamDocId); 9058 if (streamDoc) _showEmailFields(streamDoc); 9059 } else { 9060 _hideEmailFields(); 9061 } 9062 9063 syncHighlighting(); 9064 _syncStreamDocChrome(docs.get(_streamDocId)); 9065 renderTabs(); 9066 } 9067 9068 /** Simulate streaming effect for doc edits */ 9069 let _editAnimFrame = null; 9070 function _animateDocEdit(textarea, newContent) { 9071 if (_editAnimFrame) cancelAnimationFrame(_editAnimFrame); 9072 const indicator = document.getElementById('doc-stream-indicator'); 9073 if (indicator) indicator.style.display = ''; 9074 const codeEl = document.getElementById('doc-editor-code'); 9075 let cursor = document.getElementById('doc-stream-cursor'); 9076 if (!cursor) { 9077 cursor = document.createElement('span'); 9078 cursor.id = 'doc-stream-cursor'; 9079 cursor.className = 'doc-stream-cursor'; 9080 cursor.textContent = '\u258F'; 9081 } 9082 9083 const oldContent = textarea.value; 9084 9085 // Find common prefix and suffix to isolate the changed region 9086 let prefixLen = 0; 9087 while (prefixLen < oldContent.length && prefixLen < newContent.length && 9088 oldContent[prefixLen] === newContent[prefixLen]) prefixLen++; 9089 let suffixLen = 0; 9090 while (suffixLen < (oldContent.length - prefixLen) && 9091 suffixLen < (newContent.length - prefixLen) && 9092 oldContent[oldContent.length - 1 - suffixLen] === newContent[newContent.length - 1 - suffixLen]) suffixLen++; 9093 9094 const deletedText = oldContent.slice(prefixLen, oldContent.length - suffixLen); 9095 const insertedText = newContent.slice(prefixLen, newContent.length - suffixLen); 9096 const suffix = oldContent.slice(oldContent.length - suffixLen); 9097 9098 // Phase 1: delete characters one by one, then Phase 2: insert 9099 const deleteChunk = Math.max(2, Math.ceil(deletedText.length / 30)); 9100 const insertChunk = Math.max(2, Math.ceil(insertedText.length / 30)); 9101 let deletePos = deletedText.length; 9102 let insertPos = 0; 9103 let phase = deletedText.length > 0 ? 'delete' : 'insert'; 9104 9105 // Scroll to the edit region 9106 const linesBefore = oldContent.slice(0, prefixLen).split('\n').length; 9107 const lineH = parseFloat(getComputedStyle(textarea).lineHeight) || 20; 9108 textarea.scrollTop = Math.max(0, (linesBefore - 3) * lineH); 9109 9110 function tick() { 9111 if (phase === 'delete') { 9112 deletePos = Math.max(0, deletePos - deleteChunk); 9113 const current = oldContent.slice(0, prefixLen) + deletedText.slice(0, deletePos) + suffix; 9114 textarea.value = current; 9115 if (codeEl) codeEl.textContent = current + '\n'; 9116 if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor); 9117 updateLineNumbers(current); 9118 if (deletePos > 0) { 9119 _editAnimFrame = requestAnimationFrame(tick); 9120 } else { 9121 phase = 'insert'; 9122 _editAnimFrame = requestAnimationFrame(tick); 9123 } 9124 } else { 9125 insertPos = Math.min(insertPos + insertChunk, insertedText.length); 9126 const current = newContent.slice(0, prefixLen + insertPos) + suffix; 9127 textarea.value = current; 9128 if (codeEl) codeEl.textContent = current + '\n'; 9129 if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor); 9130 updateLineNumbers(current); 9131 if (insertPos < insertedText.length) { 9132 _editAnimFrame = requestAnimationFrame(tick); 9133 } else { 9134 // Done â set final content 9135 textarea.value = newContent; 9136 _editAnimFrame = null; 9137 if (indicator) indicator.style.display = 'none'; 9138 if (cursor) cursor.remove();
9139 syncHighlighting(); 9140 } 9141 } 9142 } 9143 _editAnimFrame = requestAnimationFrame(tick); 9144 } 9145 9146 /** Append streaming content to the currently-streaming doc */ 9147 let _streamHlDebounce = null; 9148 export function streamDocDelta(content) { 9149 if (!_streamDocId) return; 9150 const doc = docs.get(_streamDocId); 9151 if (doc) doc.content = content; 9152 9153 if (_streamDocId === activeDocId) { 9154 if ((doc?.language || '').toLowerCase() === 'email') { 9155 _showEmailFields(doc); 9156 return; 9157 } 9158 const textarea = document.getElementById('doc-editor-textarea'); 9159 if (textarea) { 9160 textarea.value = content; 9161 // Auto-scroll to bottom as content streams in 9162 textarea.scrollTop = textarea.scrollHeight; 9163 } 9164 // Update text and line numbers immediately, debounce expensive highlighting 9165 const codeEl = document.getElementById('doc-editor-code'); 9166 if (codeEl) codeEl.textContent = content + '\n'; 9167 updateLineNumbers(content); 9168 // Show blinking cursor at end of content 9169 let cursor = document.getElementById('doc-stream-cursor'); 9170 if (!cursor) { 9171 cursor = document.createElement('span'); 9172 cursor.id = 'doc-stream-cursor'; 9173 cursor.className = 'doc-stream-cursor'; 9174 cursor.textContent = '\u258F'; 9175 } 9176 if (codeEl && codeEl.parentElement) codeEl.parentElement.appendChild(cursor); 9177 clearTimeout(_streamHlDebounce); 9178 _streamHlDebounce = setTimeout(syncHighlighting, 150); 9179 } 9180 } 9181 9182 /** Finalize streaming â called when doc_update arrives with the real ID. 9183 * Returns the old _streamDocId so handleDocUpdate can migrate tempâreal. */ 9184 export function streamDocFinalize() { 9185 const oldId = _streamDocId; 9186 _streamDocId = null; 9187 // Hide streaming indicator + cursor 9188 const indicator = document.getElementById('doc-stream-indicator'); 9189 if (indicator) indicator.style.display = 'none'; 9190 const cursor = document.getElementById('doc-stream-cursor'); 9191 if (cursor) cursor.remove(); 9192 // Final highlighting pass + auto-detect language 9193 clearTimeout(_streamHlDebounce); 9194 syncHighlighting(); 9195 attemptAutoDetect(); 9196 return oldId; 9197 } 9198 9199 /** Handle SSE doc_update event from AI */ 9200 export function handleDocUpdate(data) { 9201 const streamingId = streamDocFinalize(); 9202 let docId = data.doc_id; 9203 const newContent = data.content || ''; 9204 9205 // Migrate streaming temp doc to real ID 9206 if (streamingId && streamingId.startsWith('_streaming_') && docs.has(streamingId)) { 9207 const tempDoc = docs.get(streamingId); 9208 docs.delete(streamingId); 9209 tempDoc.id = docId; 9210 tempDoc.version = data.version || 1; 9211 if (data.title) tempDoc.title = data.title; 9212 if (data.language) tempDoc.language = data.language; 9213 tempDoc.content = newContent; 9214 docs.set(docId, tempDoc); 9215 // Fix activeDocId reference 9216 if (activeDocId === streamingId) activeDocId = docId; 9217 } 9218 9219 // Deduplicate: if a new doc has same title as existing doc in this session, update it instead 9220 if (!docs.has(docId)) { 9221 const curSession = sessionModule?.getCurrentSessionId() || ''; 9222 let reuseId = null; 9223 9224 // First: match by title 9225 if (data.title) { 9226 for (const [existingId, existingDoc] of docs) { 9227 if (existingDoc.title === data.title && existingDoc.sessionId === curSession) { 9228 reuseId = existingId; 9229 break; 9230 } 9231 } 9232 } 9233 9234 // Second: if no title match, reuse an empty untitled doc in this session 9235 if (!reuseId) { 9236 for (const [existingId, existingDoc] of docs) { 9237 if (existingDoc.sessionId === curSession && 9238 (!existingDoc.title || existingDoc.title === 'Untitled') && 9239 (!existingDoc.content || existingDoc.content.trim() === '')) { 9240 reuseId = existingId; 9241 break; 9242 } 9243 } 9244 } 9245 9246 if (reuseId) docId = reuseId; 9247 } 9248 9249 // Capture old content before updating the map 9250 const textarea = document.getElementById('doc-editor-textarea'); 9251 const oldContent = (docId === activeDocId && textarea) ? textarea.value : ''; 9252 const isExistingDoc = docs.has(docId); 9253 9254 // Add or update in docs map 9255 if (isExistingDoc) { 9256 const doc = docs.get(docId); 9257 doc.content = newContent; 9258 doc.version = data.version || doc.version; 9259 if (data.title) doc.title = data.title; 9260 if (data.language) doc.language = data.language; 9261 } else { 9262 docs.set(docId, { 9263 id: docId, 9264 title: data.title || '', 9265 language: data.language || '', 9266 content: newContent, 9267 version: data.version || 1, 9268 sessionId: sessionModule?.getCurrentSessionId() || '', 9269 }); 9270 } 9271 9272 _syncDocIndicator(); 9273 9274 // Auto-title from content if still "Untitled" and AI didn't provide a title 9275 if (!data.title) autoTitleFromContent(newContent, docId); 9276
9277 if (!isOpen) openPanel(); 9278 9279 // Force doc button visible (overrides appearance settings & toolbar collapse) 9280 const toggleBtn = document.getElementById('overflow-doc-btn'); 9281 if (toggleBtn) { 9282 toggleBtn.style.display = ''; 9283 toggleBtn.classList.remove('toolbar-collapsed'); 9284 toggleBtn.classList.add('has-docs'); 9285 } 9286 const docInd = document.getElementById('doc-indicator-btn'); 9287 if (docInd) docInd.classList.add('visible'); 9288 9289 // Switch to this doc's tab 9290 activeDocId = docId; 9291 9292 const badge = document.getElementById('doc-version-badge'); 9293 const titleInput = document.getElementById('doc-title-input'); 9294 const langSelect = document.getElementById('doc-language-select'); 9295 9296 // Re-enable editor if it was in empty state 9297 if (textarea) { 9298 textarea.disabled = false; 9299 textarea.placeholder = 'Document content...'; 9300 } 9301 if (badge) badge.textContent = `v${data.version || 1}`; 9302 if (data.title && titleInput) titleInput.value = data.title; 9303 // Set language from data, or fall back to what the doc already has (e.g. from streaming) 9304 const docLang = data.language || (docs.has(docId) && docs.get(docId).language) || ''; 9305 if (docLang && langSelect) langSelect.value = docLang; 9306 if (!docLang) attemptAutoDetect(); 9307 const isEmailUpdate = (docLang || '').toLowerCase() === 'email'; 9308 9309 // Animate content update for edits; apply directly for creates/streaming 9310 const isEdit = !isEmailUpdate && isExistingDoc && oldContent && oldContent !== newContent && !streamingId; 9311 if (isEdit && textarea) { 9312 // Count changed lines to decide between animation and diff mode 9313 const oldLines = oldContent.split('\n'); 9314 const newLines = newContent.split('\n'); 9315 let changedLines = 0; 9316 const maxLen = Math.max(oldLines.length, newLines.length); 9317 for (let li = 0; li < maxLen; li++) { 9318 if (oldLines[li] !== newLines[li]) changedLines++; 9319 } 9320 if (changedLines >= DIFF_MODE_THRESHOLD) { 9321 enterDiffMode(oldContent, newContent); 9322 } else { 9323 _animateDocEdit(textarea, newContent); 9324 } 9325 } else { 9326 if (isEmailUpdate) { 9327 const updatedDocForEmail = docs.get(docId); 9328 if (updatedDocForEmail) { 9329 _setMarkdownPreviewActive(false, { remember: false }); 9330 _showEmailFields(updatedDocForEmail); 9331 } 9332 } else { 9333 if (textarea) textarea.value = newContent; 9334 syncHighlighting(); 9335 } 9336 } 9337 9338 // Flash the editor wrap to indicate content was updated 9339 const wrap = document.getElementById('doc-editor-wrap'); 9340 if (wrap && !isEdit) { 9341 wrap.classList.remove('doc-updated-flash'); 9342 void wrap.offsetWidth; // force reflow 9343 wrap.classList.add('doc-updated-flash'); 9344 wrap.addEventListener('animationend', () => wrap.classList.remove('doc-updated-flash'), { once: true }); 9345 } 9346 9347 // Auto-detect language for docs with no language set 9348 const updatedDoc = docs.get(docId); 9349 if (isEmailUpdate && updatedDoc) { 9350 updatedDoc.language = 'email'; 9351 if (langSelect) langSelect.value = 'email'; 9352 _showEmailFields(updatedDoc); 9353 } 9354 if (updatedDoc && !updatedDoc.userSetLanguage && !updatedDoc.language) { 9355 setTimeout(attemptAutoDetect, 100); 9356 } 9357 9358 // Show/hide format-specific buttons and auto-toggle previews 9359 const finalLang = docLang || (updatedDoc && updatedDoc.language) || ''; 9360 const mdToolbar = document.getElementById('doc-md-toolbar'); 9361 // Toolbar shown for every doc type â items inside self-gate on language. 9362 if (mdToolbar) mdToolbar.style.display = ''; 9363 // Auto-show table view for CSV after streaming 9364 if (finalLang === 'csv') { 9365 requestAnimationFrame(() => { 9366 const csvPreview = document.getElementById('doc-csv-preview'); 9367 if (csvPreview && csvPreview.style.display === 'none') toggleCsvPreview(); 9368 }); 9369 } 9370 9371 renderTabs(); 9372 9373 // Refresh the header buttons (Run/Preview â¶, edit toggles) for the active 9374 // doc after ANY update â otherwise an AI-created html/svg/code doc wouldn't 9375 // show its â¶ Run button until the page was refreshed. 9376 if (docId === activeDocId) { 9377 _syncHeaderActions(); 9378 // Form-backed (PDF) docs: re-fetch the rendered preview if it's showing. 9379 if (_isFormBackedDoc(newContent)) { 9380 const explicit = _pdfViewState.get(docId); 9381 if (explicit !== false) _refreshPdfPreviewIframe(); 9382 } 9383 } 9384 } 9385 9386 /** Toggle version history panel */ 9387 let _versionClickOutside = null; 9388 let _versionSavedContent = null; // stash current content for preview/revert 9389 async function toggleVersionHistory() { 9390 const panel = document.getElementById('doc-version-panel'); 9391 if (!panel || !activeDocId) return; 9392 9393 if (panel.classList.contains('hidden')) { 9394 // Stash current content so we can restore on close 9395 const ta = document.getElementById('doc-editor-textarea'); 9396 _versionSavedContent = ta ? ta.value : null; 9397 9398 // Position next to sidebar on desktop 9399 const sidebar = document.getElementById('sidebar'); 9400 const isMobile = window.innerWidth <= 768; 9401 if (!isMobile && sidebar) { 9402 const sidebarRight = sidebar.classList.contains('right-side'); 9403 const collapsed = document.body.classList.contains('sidebar-collapsed'); 9404 if (sidebarRight || collapsed) { 9405 panel.style.left = '0'; 9406 panel.style.right = 'auto'; 9407 } else { 9408 panel.style.left = sidebar.offsetWidth + 'px'; 9409 panel.style.right = 'auto'; 9410 } 9411 } else if (isMobile) { 9412 // Clear any stale inline positioning from a prior desktop open so the 9413 // mobile bottom-sheet (CSS) isn't pushed off-screen. 9414 panel.style.left = ''; 9415 panel.style.right = ''; 9416 panel.style.top = ''; 9417 } 9418 9419 // Move panel to body so it's not clipped by doc pane overflow 9420 if (panel.parentElement !== document.body) { 9421 document.body.appendChild(panel); 9422 } 9423 9424 panel.classList.remove('hidden'); 9425 await loadVersionHistory(); 9426 // Close on click outside 9427 setTimeout(() => { 9428 _versionClickOutside = (e) => { 9429 if (!panel.contains(e.target) && e.target.id !== 'doc-version-badge') { 9430 _closeVersionPanel(); 9431 } 9432 }; 9433 document.addEventListener('click', _versionClickOutside, true); 9434 }, 0); 9435 } else { 9436 _closeVersionPanel(); 9437 } 9438 } 9439 9440 function _closeVersionPanel() { 9441 const panel = document.getElementById('doc-version-panel'); 9442 if (panel) panel.classList.add('hidden'); 9443 // Restore to latest (stashed) content 9444 if (_versionSavedContent !== null) { 9445 const ta = document.getElementById('doc-editor-textarea'); 9446 if (ta) ta.value = _versionSavedContent;
9447 syncHighlighting(); 9448 _versionSavedContent = null; 9449 } 9450 if (_versionClickOutside) { 9451 document.removeEventListener('click', _versionClickOutside, true); 9452 _versionClickOutside = null; 9453 } 9454 } 9455 9456 /** Build a short diff summary between two strings */ 9457 function _buildDiffSummary(oldText, newText) { 9458 if (!oldText && !newText) return ''; 9459 const oldLines = (oldText || '').split('\n'); 9460 const newLines = (newText || '').split('\n'); 9461 const added = [], removed = []; 9462 // Simple line diff â collect changed lines 9463 const maxCheck = Math.max(oldLines.length, newLines.length); 9464 for (let i = 0; i < maxCheck; i++) { 9465 const ol = oldLines[i], nl = newLines[i]; 9466 if (ol === nl) continue; 9467 if (ol !== undefined && (nl === undefined || ol !== nl)) removed.push(ol.trim()); 9468 if (nl !== undefined && (ol === undefined || ol !== nl)) added.push(nl.trim()); 9469 } 9470 // Show up to 3 changes 9471 const parts = []; 9472 for (const line of removed.slice(0, 2)) { 9473 if (line) parts.push(`<span class="diff-del">${_escHtml(line.slice(0, 60))}</span>`); 9474 } 9475 for (const line of added.slice(0, 2)) { 9476 if (line) parts.push(`<span class="diff-add">${_escHtml(line.slice(0, 60))}</span>`); 9477 } 9478 const extra = (added.length + removed.length) - 4; 9479 if (extra > 0) parts.push(`<span>+${extra} more changes</span>`); 9480 return parts.join('<br>'); 9481 } 9482 function _escHtml(s) { 9483 return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 9484 } 9485 9486 /** Load version history list */ 9487 async function loadVersionHistory() { 9488 if (!activeDocId) return; 9489 const list = document.getElementById('doc-version-list'); 9490 if (!list) return; 9491 9492 try { 9493 const res = await fetch(`${API_BASE}/api/document/${activeDocId}/versions`); 9494 const versions = await res.json(); 9495 9496 // Build diff summaries between consecutive versions 9497 const diffs = []; 9498 for (let i = 0; i < versions.length; i++) { 9499 if (i < versions.length - 1) { 9500 diffs.push(_buildDiffSummary(versions[i + 1].content, versions[i].content)); 9501 } else { 9502 diffs.push(''); 9503 } 9504 } 9505 9506 list.innerHTML = versions.map((v, i) => ` 9507 <div class="doc-version-item" data-version="${v.version_number}"> 9508 <div class="doc-version-info"> 9509 <span class="doc-version-num">v${v.version_number}</span> 9510 ${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>`} 9511 </div> 9512 ${v.summary ? `<div class="doc-version-summary">${v.summary}</div>` : ''} 9513 ${diffs[i] ? `<div class="doc-version-diff">${diffs[i]}</div>` : ''} 9514 ${i > 0 ? `<button class="doc-version-restore" data-version="${v.version_number}">Restore</button>` : ''} 9515 </div> 9516 `).join(''); 9517 9518 // Wire restore buttons 9519 list.querySelectorAll('.doc-version-restore').forEach(btn => { 9520 btn.addEventListener('click', (e) => { 9521 e.stopPropagation(); 9522 restoreVersion(parseInt(btn.dataset.version)); 9523 }); 9524 }); 9525 9526 // Wire click to preview version + active state 9527 list.querySelectorAll('.doc-version-item').forEach(item => { 9528 item.addEventListener('click', (e) => { 9529 if (e.target.classList.contains('doc-version-restore')) return; 9530 // Toggle active state 9531 list.querySelectorAll('.doc-version-item.active').forEach(el => el.classList.remove('active')); 9532 item.classList.add('active'); 9533 previewVersion(parseInt(item.dataset.version)); 9534 }); 9535 }); 9536 } catch (e) { 9537 list.innerHTML = '<div style="padding:8px;opacity:0.5;">Failed to load versions</div>'; 9538 } 9539 } 9540 9541 /** Preview a specific version in the editor (without saving) */ 9542 async function previewVersion(num) { 9543 if (!activeDocId) return; 9544 try { 9545 const res = await fetch(`${API_BASE}/api/document/${activeDocId}/version/${num}`); 9546 const ver = await res.json(); 9547 const textarea = document.getElementById('doc-editor-textarea'); 9548 if (textarea) textarea.value = ver.content || '';
9549 syncHighlighting(); 9550 } catch (e) { 9551 console.error('Failed to preview version:', e); 9552 } 9553 } 9554 9555 /** Restore an old version (creates new version) */ 9556 async function restoreVersion(num) { 9557 if (!activeDocId) return; 9558 try { 9559 const res = await fetch(`${API_BASE}/api/document/${activeDocId}/restore/${num}`, { 9560 method: 'POST', 9561 }); 9562 const doc = await res.json(); 9563 populateEditor(doc); 9564 // Clear stash â restored content IS the new latest 9565 _versionSavedContent = null; 9566 // Update map 9567 if (docs.has(activeDocId)) { 9568 const d = docs.get(activeDocId); 9569 d.content = doc.current_content || ''; 9570 d.version = doc.version_count || 1; 9571 } 9572 await loadVersionHistory(); 9573 if (uiModule) uiModule.showToast(`Restored to v${num}`); 9574 } catch (e) { 9575 console.error('Failed to restore version:', e); 9576 if (uiModule) uiModule.showError('Failed to restore version'); 9577 } 9578 } 9579 9580 /** Update document title via PATCH */ 9581 async function updateTitle(overrideDocId, overrideTitle) { 9582 const docId = overrideDocId || activeDocId; 9583 if (!docId) return; 9584 const title = overrideTitle || document.getElementById('doc-title-input')?.value; 9585 if (!title) return; 9586 try { 9587 await fetch(`${API_BASE}/api/document/${docId}`, { 9588 method: 'PATCH', 9589 headers: { 'Content-Type': 'application/json' }, 9590 body: JSON.stringify({ title }), 9591 }); 9592 if (docs.has(docId)) { 9593 docs.get(docId).title = title; 9594 renderTabs(); 9595 } 9596 } catch (e) { 9597 console.error('Failed to update title:', e); 9598 } 9599 } 9600 9601 /** Auto-detect title from content if still "Untitled" */ 9602 function autoTitleFromContent(content, docId) { 9603 const id = docId || activeDocId; 9604 if (!id) return; 9605 const doc = docs.get(id); 9606 if (!doc || (doc.title && doc.title !== '' && doc.title !== 'Untitled')) return; 9607 9608 const text = (content || '').trimStart(); 9609 if (!text) return; 9610 9611 let title = null; 9612 9613 // Markdown header: # Title 9614 const mdMatch = text.match(/^#{1,3}\s+(.+)/m); 9615 if (mdMatch) { 9616 title = mdMatch[1].trim(); 9617 } 9618 9619 // HTML heading: <h1>Title</h1> 9620 if (!title) { 9621 const htmlMatch = text.match(/<h[1-3][^>]*>([^<]+)<\/h[1-3]>/i); 9622 if (htmlMatch) title = htmlMatch[1].trim(); 9623 } 9624 9625 // First non-empty line as fallback (only if short enough to be a title) 9626 if (!title) { 9627 const firstLine = text.split('\n').find(l => l.trim().length > 0); 9628 if (firstLine) { 9629 const cleaned = firstLine.trim(); 9630 if (cleaned.length <= 60 && cleaned.length >= 2) { 9631 title = cleaned; 9632 } 9633 } 9634 } 9635 9636 if (!title) return; 9637 9638 // Clean up: strip trailing punctuation like : or ... 9639 title = title.replace(/[:#*`]+$/g, '').trim(); 9640 if (title.length > 50) title = title.slice(0, 48) + '...'; 9641 if (!title) return; 9642 9643 updateTitle(id, title); 9644 const titleInput = document.getElementById('doc-title-input'); 9645 if (titleInput && id === activeDocId) titleInput.value = title; 9646 } 9647 9648 /** Update document language via PATCH */ 9649 async function updateLanguage() { 9650 if (!activeDocId) return; 9651 const select = document.getElementById('doc-language-select'); 9652 if (!select) return; 9653 try { 9654 await fetch(`${API_BASE}/api/document/${activeDocId}`, { 9655 method: 'PATCH', 9656 headers: { 'Content-Type': 'application/json' }, 9657 body: JSON.stringify({ language: select.value }), 9658 }); 9659 if (docs.has(activeDocId)) { 9660 docs.get(activeDocId).language = select.value; 9661 renderTabs(); 9662 } 9663 } catch (e) { 9664 console.error('Failed to update language:', e); 9665 } 9666 } 9667 9668 /** Clear all tab state (e.g. on session switch) */ 9669 export function clearAll() { 9670 docs.clear(); 9671 activeDocId = null; 9672 _lastSessionId = ''; 9673 if (isOpen) closePanel(); 9674 _syncDocIndicator(); 9675 } 9676 9677 export function isPanelOpen() { 9678 return isOpen; 9679 } 9680 9681 export function getCurrentDocId() { 9682 return activeDocId; 9683 } 9684 9685 /** Find an open email tab by source UID + folder. Returns docId or null. */ 9686 export function findEmailDocId(uid, folder) { 9687 if (uid == null) return null; 9688 const wantUid = String(uid); 9689 const wantFolder = (folder || '').trim(); 9690 for (const [id, d] of docs) { 9691 if (d.language !== 'email') continue; 9692 const fields = _parseEmailHeader(d.content || ''); 9693 if (fields.sourceUid && String(fields.sourceUid) === wantUid && 9694 (!wantFolder || (fields.sourceFolder || '').trim() === wantFolder)) { 9695 return id; 9696 } 9697 } 9698 return null; 9699 } 9700 9701 9702 9703const documentModule = { 9704 init, 9705 openPanel, 9706 closePanel, 9707 swapSide, 9708 createDocument, 9709 newDocument, 9710 loadDocument, 9711 injectFreshDoc, 9712 ensurePaneMounted: _ensureDocPaneMounted, 9713 loadSessionDocs, 9714 ensureDocPanel, 9715 saveDocument,
9716 handleDocUpdate, 9717 handleDocSuggestions, 9718 streamDocOpen, 9719 streamDocDelta, 9720 streamDocFinalize, 9721 isPanelOpen, 9722 enterDiffMode, 9723 exitDiffMode, 9724 isDiffModeActive, 9725 getCurrentDocId, 9726 findEmailDocId, 9727 getSelectionContext, 9728 clearSelection, 9729 clearAll, 9730 openLibrary, 9731 closeLibrary, 9732 isLibraryOpen, 9733}; 9734 9735export default documentModule; 9736window.documentModule = documentModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.