PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/document.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:17 UTC 434,026 bytes, 9,736 lines download raw bytes

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">&#x2039;</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)">&times;</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">&#x203A;</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">&uarr;</button>
3922        <button id="doc-find-next" class="doc-find-nav" title="Next">&darr;</button>
3923        <button id="doc-find-close" class="doc-find-close" title="Close">&times;</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">&times;</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">&lsaquo;</button>
7112          <span class="doc-suggestion-counter">${num} / ${_suggestionTotal}</span>
7113          <button class="doc-suggestion-nav-btn doc-suggestion-next" title="Next">&rsaquo;</button>
7114        </div>
7115        <button class="doc-suggestion-close" title="Close all suggestions">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</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,'&lt;')}</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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.