// static/js/document.js
/**
* Document editor module — multi-document tabbed panel alongside chat.
* Supports multiple open documents with tab switching, per-doc state,
* and theme-aware styling.
*/
import uiModule from './ui.js';
import sessionModule from './sessions.js';
import emojiPicker from './emojiPicker.js';
import markdownModule from './markdown.js';
import codeRunnerModule from './codeRunner.js';
import { langIcon } from './langIcons.js';
import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js';
import * as Modals from './modalManager.js';
let API_BASE = '';
let isOpen = false;
let _hlDebounce = null;
let _isEditingTabTitle = false;
let _autoDetectDebounce = null;
let _autoTitleDebounce = null;
let _autoSaveDebounce = null;
let _animationInProgress = false;
let _animationCancel = null; // function to cancel current animation
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
let _emailAccountsCache = null;
let _emailAccountsCacheAt = 0;
// Diff mode state
let _diffModeActive = false;
let _diffOldContent = null;
let _diffNewContent = null;
let _diffChunks = []; // [{id, oldLines, newLines, startLine, resolved, accepted}]
let _diffUnresolvedCount = 0;
// Language auto-detection config
const AUTO_DETECT_DELAY = 500;
const AUTO_DETECT_MIN_CHARS = 30;
const AUTO_DETECT_MIN_RELEVANCE = 8;
const AUTO_DETECT_SAMPLE_SIZE = 2000;
const HLJS_TO_DROPDOWN = {
python: 'python', javascript: 'javascript', typescript: 'typescript',
xml: 'html', html: 'html', css: 'css', markdown: 'markdown',
json: 'json', yaml: 'yaml', bash: 'bash', shell: 'bash',
sql: 'sql', rust: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp',
csv: 'csv',
};
// Languages rendered in the sandboxed preview iframe. SVG and XML markup
// render as inline content in an HTML document, so they share the HTML
// "Run / Preview" path. (hljs maps detected `xml` → `html` already; this also
// covers the doc being explicitly typed svg/xml.)
const _isRenderLang = (l) => ['html', 'svg', 'xml'].includes((l || '').toLowerCase());
// Languages that get the segmented Code / Run-or-View toggle in the toolbar
// (the same UX as markdown's Edit / Preview switch). CSV's "run" view is the
// table; Python/JS/etc.'s is the code-run output; HTML/SVG/XML render via
// the iframe preview.
const _hasViewToggle = (l) => {
const lang = (l || '').toLowerCase();
return [
'csv', 'python', 'javascript', 'typescript', 'bash', 'sh', 'shell',
'php', 'ruby', 'sql', 'java', 'go', 'rust',
'c', 'cpp', 'c++', 'csharp', 'c#',
'yaml', 'json', 'css',
'ini', 'toml',
].includes(lang) || _isRenderLang(lang);
};
async function _getEmailAccountsCached() {
const now = Date.now();
if (_emailAccountsCache && (now - _emailAccountsCacheAt) < 30000) return _emailAccountsCache;
try {
const res = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!res.ok) throw new Error('accounts failed');
const data = await res.json();
_emailAccountsCache = Array.isArray(data.accounts) ? data.accounts : [];
} catch (_) {
_emailAccountsCache = [];
}
_emailAccountsCacheAt = now;
return _emailAccountsCache;
}
function _accountCanSend(account) {
return !!(account && account.smtp_host && account.smtp_user && account.has_smtp_password);
}
async function _resolveComposeSendAccountId() {
const activeAccountId = window.__odysseusActiveEmailAccount || null;
if (!activeAccountId) return null;
const accounts = await _getEmailAccountsCached();
const activeAccount = accounts.find(a => String(a.id) === String(activeAccountId));
if (!activeAccount || _accountCanSend(activeAccount)) return activeAccountId;
if (uiModule) uiModule.showToast('Selected email account is receive-only; using your SMTP account.');
return null;
}
// Inject tab menu styles immediately (must exist before any hover)
{
const s = document.createElement('style');
s.id = 'doc-tab-menu-styles';
s.textContent = `.doc-tab-menu-btn{background:none!important;border:none!important;outline:none!important;box-shadow:none!important;color:var(--fg);opacity:0.25;cursor:pointer;padding:2px 4px!important;height:auto!important;line-height:1;transition:opacity .15s;flex-shrink:0;-webkit-appearance:none;appearance:none}.doc-tab-menu-btn:focus,.doc-tab-menu-btn:active{outline:none!important;box-shadow:none!important;background:none!important}.doc-tab:hover .doc-tab-menu-btn{opacity:.5}.doc-tab-menu-btn:hover{opacity:1!important}.doc-tab-dropdown .dropdown-item-compact{padding:6px 8px;border-radius:6px;cursor:pointer;white-space:nowrap;border-bottom:none;display:flex;align-items:center;gap:10px;font-size:11px}.doc-tab-dropdown .dropdown-item-compact:hover{background:color-mix(in srgb,var(--fg) 8%,transparent)}.doc-tab-dropdown .dropdown-item-compact .dropdown-icon{width:14px;height:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;opacity:0.5}.doc-tab-dropdown .dropdown-divider{height:1px;margin:3px 0;background:color-mix(in srgb,var(--border) 40%,transparent)}.doc-tab-action-delete{color:var(--red,#e06c75)!important}.doc-tab-action-delete .dropdown-icon{opacity:0.7!important}`;
document.head.appendChild(s);
}
// Multi-document state
let activeDocId = null; // currently visible doc
let _lastSessionId = ''; // session context for "+" button
const docs = new Map(); // docId -> { id, title, language, content, version, sessionId }
const _docOpenKey = (sessionId) => 'odysseus-doc-open-' + sessionId;
const _docMinimizedKey = (sessionId) => 'odysseus-doc-minimized-' + sessionId;
function _markDocVisibleState(sessionId, state) {
if (!sessionId) return;
if (state === 'open') {
localStorage.setItem(_docOpenKey(sessionId), '1');
localStorage.removeItem(_docMinimizedKey(sessionId));
} else if (state === 'minimized') {
localStorage.removeItem(_docOpenKey(sessionId));
localStorage.setItem(_docMinimizedKey(sessionId), '1');
} else {
localStorage.removeItem(_docOpenKey(sessionId));
localStorage.removeItem(_docMinimizedKey(sessionId));
}
}
/** Switch chat to agent mode if not already */
function _ensureAgentMode() {
const ab = document.getElementById('mode-agent-btn');
const cb = document.getElementById('mode-chat-btn');
if (ab && !ab.classList.contains('active')) {
ab.click();
}
}
export function init(apiBase) {
API_BASE = apiBase;
initLibrary({
apiBase,
esc: _esc,
getDocs: () => docs,
isOpen: () => isOpen,
createDocument,
loadDocument,
switchToDoc,
openPanel,
addDocToTabs,
syncDocIndicator: _syncDocIndicator,
});
_maybeOpenDocFromHash();
window.addEventListener('hashchange', _maybeOpenDocFromHash);
}
/** Update overflow-doc-btn accent indicator, toolbar indicator, and session list icon */
function _syncDocIndicator() {
const btn = document.getElementById('overflow-doc-btn');
// Has docs = at least one non-empty doc in the map
const hasDocs = docs.size > 0;
if (btn) btn.classList.toggle('has-docs', hasDocs);
// Show/hide the toolbar doc indicator when docs exist
const indicator = document.getElementById('doc-indicator-btn');
if (indicator) indicator.classList.toggle('visible', hasDocs);
// Hide overflow menu item when indicator is shown outside
if (btn) btn.style.display = hasDocs ? 'none' : '';
// Update session list icon
const sid = sessionModule?.getCurrentSessionId();
if (sid && sessionModule.setSessionHasDocs) {
sessionModule.setSessionHasDocs(sid, hasDocs);
}
}
// ---- Tab bar rendering ----
function updateArrowVisibility(scrollArea, leftBtn, rightBtn) {
const atLeft = scrollArea.scrollLeft <= 0;
const atRight = scrollArea.scrollLeft + scrollArea.clientWidth >= scrollArea.scrollWidth - 1;
leftBtn.style.display = atLeft ? 'none' : '';
rightBtn.style.display = atRight ? 'none' : '';
// Toggle the edge-mask classes so the fade gradient drops to flat on the
// side that has nothing to scroll to. Without this the left/right fade
// reads as a permanent shadow even when no arrow is showing.
scrollArea.classList.toggle('is-at-left', atLeft);
scrollArea.classList.toggle('is-at-right', atRight);
}
// Mobile swipe-to-dismiss for the doc sheet. Mirrors the shared bottom-sheet
// gesture in ui.js (finger-following drag, velocity-based dismiss, rubber-band
// on up-drag, spring snap-back) so it feels identical to the other windows —
// but dismisses through the doc panel's own closePanel() lifecycle.
function _wireSwipeDismiss(el) {
if (!el) return;
const DISMISS_THRESHOLD = 50; // px
const VELOCITY_THRESHOLD = 0.3; // px/ms — fast flick dismisses below threshold
const RUBBER_RESISTANCE = 0.35; // resistance when dragging up past origin
let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0;
let dragging = false, cancelled = false;
const getPane = () => document.getElementById('doc-editor-pane');
let pane = null;
el.addEventListener('touchstart', (e) => {
if (window.innerWidth > 768 || e.touches.length !== 1) return;
pane = getPane();
if (!pane) return;
const t = e.touches[0];
startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp;
velocity = 0; dragging = false; cancelled = false;
}, { passive: true });
el.addEventListener('touchmove', (e) => {
if (cancelled || !pane || window.innerWidth > 768) return;
const t = e.touches[0];
const dx = Math.abs(t.clientX - startX);
const dy = t.clientY - startY;
if (!dragging) {
if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } // horizontal → tab scroll
if (Math.abs(dy) > 8) {
dragging = true;
// Clear the open animation — its `both` fill-mode otherwise pins
// transform and overrides our inline finger-following transform.
pane.style.animation = 'none';
pane.style.transition = 'none';
pane.style.willChange = 'transform';
} else return;
}
const dt = e.timeStamp - lastT;
if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4;
lastY = t.clientY; lastT = e.timeStamp;
e.preventDefault();
pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER_RESISTANCE}px)`;
}, { passive: false });
const endSwipe = () => {
if (!dragging || !pane) { pane = null; return; }
const p = pane; pane = null; dragging = false;
p.style.willChange = '';
const dy = lastY - startY;
const shouldDismiss = dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD);
if (shouldDismiss) {
closePanel('down');
} else {
p.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)';
p.style.transform = '';
setTimeout(() => { p.style.transition = ''; }, 260);
}
};
el.addEventListener('touchend', endSwipe, { passive: true });
el.addEventListener('touchcancel', endSwipe, { passive: true });
}
function renderTabs() {
if (_isEditingTabTitle) return; // Don't rebuild while editing a title
const tabBar = document.getElementById('doc-tab-bar');
if (!tabBar) return;
// Build tab HTML with scroll arrows
// When doc panel is on right (default), + goes on far left; on left, + goes inside scroll area
const paneEl = document.querySelector('.doc-editor-pane');
const isDocLeft = paneEl && paneEl.classList.contains('doc-left');
let html = '';
html += '';
html += '
';
const curSession = sessionModule?.getCurrentSessionId() || '';
let _anyTab = false;
for (const [id, doc] of docs) {
// Only show tabs for the current session
if (doc.sessionId && curSession && doc.sessionId !== curSession) continue;
_anyTab = true;
const isActive = id === activeDocId;
const title = doc.title || 'Untitled';
const shortTitle = title.length > 24 ? title.slice(0, 22) + '...' : title;
const menuBtn = ``;
const ver = doc.version || doc.version_count || 1;
const verChip = `v${ver}`;
// Language icon before the title — same family as the meta-line / picker
// icons. Hidden via :empty CSS when the doc has no useful language.
const lic = (doc.language && doc.language !== 'text')
? langIcon(doc.language, 12, { style: 'opacity:0.65;flex-shrink:0;color:currentColor;margin-right:4px;' })
: '';
const langChip = `${lic}`;
html += `
${verChip}${langChip}${shortTitle}
`;
}
// Empty state (panel open, no doc yet): show a ghost "Untitled" tab so it's
// obvious you're in a fresh document rather than staring at a blank pane.
if (!_anyTab && isOpen && !activeDocId) {
html += `
Untitled
`;
}
html += ``;
html += '
';
html += '';
tabBar.innerHTML = html;
// Wire scroll arrows
const scrollArea = document.getElementById('doc-tab-scroll');
const leftBtn = document.getElementById('doc-tab-left');
const rightBtn = document.getElementById('doc-tab-right');
if (scrollArea && leftBtn && rightBtn) {
leftBtn.addEventListener('click', () => scrollArea.scrollBy({ left: -120, behavior: 'smooth' }));
rightBtn.addEventListener('click', () => scrollArea.scrollBy({ left: 120, behavior: 'smooth' }));
updateArrowVisibility(scrollArea, leftBtn, rightBtn);
scrollArea.addEventListener('scroll', () => updateArrowVisibility(scrollArea, leftBtn, rightBtn));
}
// Mobile: the tab bar doubles as a drag zone — swipe down to dismiss.
if (!tabBar._swipeWired) { tabBar._swipeWired = true; _wireSwipeDismiss(tabBar); }
// Bring the clicked tab fully into view — the scroll area has an 18px
// fade-mask at each edge plus the < / > arrow buttons; without this, the
// rightmost tab stays partially under the fade so the user can't see its
// close button or version chip.
const _scrollTabIntoView = (tab, behavior = 'smooth') => {
const sa = document.getElementById('doc-tab-scroll');
if (!sa || !tab) return;
const EDGE_PAD = 30;
const tabLeft = tab.offsetLeft;
const tabRight = tabLeft + tab.offsetWidth;
const visLeft = sa.scrollLeft + EDGE_PAD;
const visRight = sa.scrollLeft + sa.clientWidth - EDGE_PAD;
if (tabRight > visRight) {
sa.scrollTo({ left: sa.scrollLeft + tabRight - visRight, behavior });
} else if (tabLeft < visLeft) {
sa.scrollTo({ left: Math.max(0, sa.scrollLeft + tabLeft - visLeft), behavior });
}
};
// Wire tab clicks (delayed to allow dblclick on title)
let _tabClickTimer = null;
tabBar.querySelectorAll('.doc-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
// Check if click was on or inside the close/play button
if (e.target.closest('.doc-tab-close') || e.target.closest('.doc-tab-play') || e.target.closest('.doc-tab-menu-btn') || e.target.closest('.doc-tab-version')) return;
if (_isEditingTabTitle) return;
// If clicking the title span, delay to allow dblclick
if (e.target.classList.contains('doc-tab-title')) {
clearTimeout(_tabClickTimer);
_tabClickTimer = setTimeout(() => { switchToDoc(tab.dataset.docId); _scrollTabIntoView(tab); }, 250);
} else {
switchToDoc(tab.dataset.docId);
_scrollTabIntoView(tab);
}
});
tab.addEventListener('dblclick', (e) => {
clearTimeout(_tabClickTimer);
const titleSpan = tab.querySelector('.doc-tab-title');
if (!titleSpan) return;
e.stopPropagation();
const docId = tab.dataset.docId;
const doc = docs.get(docId);
if (!doc) return;
startTitleEdit(titleSpan, docId, doc);
});
});
// Wire close buttons — use delegation from tab bar for reliability
// Remove previous handler to prevent accumulation across renderTabs calls
if (tabBar._closeHandler) tabBar.removeEventListener('click', tabBar._closeHandler);
tabBar._closeHandler = (e) => {
const verBtn = e.target.closest('.doc-tab-version');
if (verBtn) {
e.stopPropagation();
const docId = verBtn.dataset.docId;
if (docId) { if (docId !== activeDocId) switchToDoc(docId); toggleVersionHistory(); }
return;
}
const playBtn = e.target.closest('.doc-tab-play');
if (playBtn) {
e.stopPropagation();
const docId = playBtn.dataset.docId;
if (docId) {
if (docId !== activeDocId) switchToDoc(docId);
toggleHtmlPreview();
}
return;
}
const menuBtnEl = e.target.closest('.doc-tab-menu-btn');
if (menuBtnEl) {
e.stopPropagation();
const docId = menuBtnEl.dataset.docId;
if (docId) showDocTabMenu(menuBtnEl, docId);
return;
}
const closeBtn = e.target.closest('.doc-tab-close');
if (!closeBtn) return;
e.stopPropagation();
const docId = closeBtn.dataset.docId;
if (docId) closeTab(docId);
};
tabBar.addEventListener('click', tabBar._closeHandler);
// Wire drag-to-reorder
initTabDragReorder(tabBar);
// Wire new doc button
const newBtn = document.getElementById('doc-tab-new-btn');
if (newBtn) {
newBtn.addEventListener('click', async () => {
let sessionId = docs.get(activeDocId)?.sessionId
|| _lastSessionId
|| (sessionModule && sessionModule.getCurrentSessionId());
if (!sessionId) {
try {
sessionId = await _autoCreateSession();
} catch (e) {
console.error('Failed to auto-create session for document:', e);
return;
}
}
createDocument(sessionId);
});
}
// Scroll active tab into view after DOM is laid out
requestAnimationFrame(() => {
const at = document.getElementById('doc-tab-scroll')?.querySelector('.doc-tab.active');
_scrollTabIntoView(at, 'auto');
});
}
/** Start inline editing of a tab title */
function startTitleEdit(titleSpan, docId, doc) {
if (_isEditingTabTitle) return;
_isEditingTabTitle = true;
const fullTitle = doc.title || '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'doc-tab-title-input';
input.value = fullTitle;
titleSpan.replaceWith(input);
input.focus();
input.select();
function commitEdit() {
if (!_isEditingTabTitle) return;
const newTitle = input.value.trim();
_isEditingTabTitle = false;
doc.title = newTitle;
if (docId === activeDocId) {
const titleInput = document.getElementById('doc-title-input');
if (titleInput) titleInput.value = newTitle;
}
updateTitle(docId, newTitle);
renderTabs();
}
function cancelEdit() {
_isEditingTabTitle = false;
renderTabs();
}
input.addEventListener('blur', commitEdit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.removeEventListener('blur', commitEdit);
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
input.removeEventListener('blur', commitEdit);
cancelEdit();
}
});
}
/** Drag-to-reorder tabs */
function initTabDragReorder(tabBar) {
let dragId = null;
tabBar.querySelectorAll('.doc-tab').forEach(tab => {
tab.addEventListener('dragstart', (e) => {
dragId = tab.dataset.docId;
tab.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
tab.addEventListener('dragend', () => {
tab.classList.remove('dragging');
dragId = null;
tabBar.querySelectorAll('.doc-tab').forEach(t => t.classList.remove('drag-over'));
});
tab.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (tab.dataset.docId !== dragId) {
tab.classList.add('drag-over');
}
});
tab.addEventListener('dragleave', () => {
tab.classList.remove('drag-over');
});
tab.addEventListener('drop', (e) => {
e.preventDefault();
tab.classList.remove('drag-over');
const targetId = tab.dataset.docId;
if (!dragId || dragId === targetId) return;
// Reorder the docs Map: move dragId before targetId
const entries = [...docs.entries()];
const fromIdx = entries.findIndex(([k]) => k === dragId);
const toIdx = entries.findIndex(([k]) => k === targetId);
if (fromIdx === -1 || toIdx === -1) return;
const [moved] = entries.splice(fromIdx, 1);
entries.splice(toIdx, 0, moved);
docs.clear();
for (const [k, v] of entries) docs.set(k, v);
renderTabs();
});
});
}
/** Show empty state when no documents exist yet */
function showEmptyState() {
activeDocId = null;
const textarea = document.getElementById('doc-editor-textarea');
const langSelect = document.getElementById('doc-language-select');
const badge = document.getElementById('doc-version-badge');
if (textarea) textarea.value = '';
if (textarea) textarea.placeholder = 'Start typing or paste text to create a document...';
if (textarea) textarea.disabled = false;
if (langSelect) langSelect.value = '';
if (badge) badge.textContent = '';
_hideLoadingOverlay();
syncHighlighting();
renderTabs();
}
let _loadingSpinner = null;
function _showLoadingOverlay() {
const wrap = document.getElementById('doc-editor-wrap');
if (!wrap) return;
let overlay = wrap.querySelector('.doc-loading-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'doc-loading-overlay';
wrap.appendChild(overlay);
}
overlay.innerHTML = '';
overlay.style.display = '';
_loadingSpinner = spinnerModule.create('', 'clean', 'whirlpool');
const el = _loadingSpinner.createElement();
overlay.appendChild(el);
_loadingSpinner.start();
}
function _hideLoadingOverlay() {
if (_loadingSpinner) { _loadingSpinner.destroy(); _loadingSpinner = null; }
const overlay = document.querySelector('.doc-loading-overlay');
if (overlay) overlay.style.display = 'none';
}
/** Show/hide the unified action button in the header based on current language */
function _isFormBackedDoc(content) {
const c = content || '';
return /[ \t]*$/gm;
}
// Bullet lines are single-line, so newlines in the value are escaped to
// \n (backslash-n) for storage and unescaped on parse. Backslashes are
// escaped first so the reverse mapping is unambiguous.
function _escapeAnnotationValue(s) {
return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
}
function _unescapeAnnotationValue(s) {
return String(s || '').replace(/\\(.)/g, (m, c) => c === 'n' ? '\n' : c === '\\' ? '\\' : m);
}
function _parseAnnotations(md) {
const out = [];
const re = _annotationRegexGlobal();
let m;
while ((m = re.exec(md || '')) !== null) {
const rawVal = m[1] === '_(empty)_' ? '' : _unescapeAnnotationValue(m[1]);
out.push({
value: rawVal,
id: m[2],
page: parseInt(m[3], 10),
x: parseFloat(m[4]),
y: parseFloat(m[5]),
w: parseFloat(m[6]),
h: parseFloat(m[7]),
kind: m[8] || 'text',
lineHeight: m[9] ? parseFloat(m[9]) : 1.3,
});
}
return out;
}
function _annotationLine(a) {
const kind = a.kind || 'text';
const lh = (a.lineHeight && Number.isFinite(a.lineHeight)) ? a.lineHeight : 1.3;
const escaped = a.value === '' || a.value == null ? '_(empty)_' : _escapeAnnotationValue(a.value);
return `- ${escaped} `;
}
// Strip every annotation bullet + the "## Annotations" section, then
// re-emit them at the end. Cleanest way to keep the section in sync with
// the live set of refs without diffing line-by-line.
function _writeAnnotations(md, annotations) {
let out = (md || '').replace(_annotationRegexGlobal(), '');
out = out.replace(/\n##\s+Annotations\s*\r?\n+/g, '\n');
out = out.replace(/\n{3,}/g, '\n\n');
if (!annotations.length) return out;
if (!out.endsWith('\n')) out += '\n';
out += '\n## Annotations\n\n';
for (const a of annotations) out += _annotationLine(a) + '\n';
return out;
}
function _newAnnotationId() {
return 'ann-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
}
function _pdfMarkdownFromLive(docId = activeDocId) {
const doc = docs.get(docId);
if (!doc) return null;
const annotations = _pdfPaneAnnotationsByDoc.get(docId) || [];
return _writeAnnotations(doc.content || '', annotations.map(a => {
let value = '';
if (a.kind === 'check') {
value = '✓';
} else if (a.kind === 'signature') {
const sid = a.el && a.el.dataset && a.el.dataset.signatureId;
value = sid ? `signature:${sid}` : '';
} else {
value = (a.el && typeof a.el.value === 'string') ? a.el.value : '';
}
return {
id: a.id, page: a.page, x: a.x, y: a.y, w: a.w, h: a.h,
kind: a.kind || 'text',
lineHeight: a.lineHeight || 1.3,
value,
};
}));
}
function _pushPdfUndoSnapshot(docId = activeDocId) {
const md = _pdfMarkdownFromLive(docId);
if (md == null) return;
const stack = _pdfUndoStackByDoc.get(docId) || [];
if (stack[stack.length - 1] === md) return;
stack.push(md);
if (stack.length > 50) stack.shift();
_pdfUndoStackByDoc.set(docId, stack);
}
async function _undoPdfPaneAction() {
const docId = activeDocId;
const stack = _pdfUndoStackByDoc.get(docId) || [];
const prev = stack.pop();
if (!prev) return false;
_pdfUndoStackByDoc.set(docId, stack);
if (_pdfPaneSaveTimer) {
clearTimeout(_pdfPaneSaveTimer);
_pdfPaneSaveTimer = null;
}
const doc = docs.get(docId);
if (!doc) return false;
doc.content = prev;
const ta = document.getElementById('doc-editor-textarea');
if (ta) ta.value = prev;
_setPdfSaveStatus('saving');
try {
const res = await fetch(`${API_BASE}/api/document/${docId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prev }),
});
if (!res.ok) throw new Error(res.statusText || String(res.status));
_setPdfSaveStatus('saved');
_renderPdfPane();
return true;
} catch (e) {
_setPdfSaveStatus('error', e.message || 'Undo failed');
return true;
}
}
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
// next click on a page consumes it. null means clicks do nothing.
let _pdfDropMode = null;
// Per-doc last-used line spacing for text annotations. Once the user picks
// 1.6 for one box, every text box dropped after that defaults to 1.6.
const _pdfLastLineHeight = new Map(); // docId -> number
function _setPdfDropMode(mode) {
_pdfDropMode = mode;
const pane = document.getElementById('doc-pdf-view');
if (pane) pane.style.cursor = mode ? 'crosshair' : '';
// Highlight the active toolbar button so users see which mode is on.
for (const id of ['doc-pdf-add-text-btn', 'doc-pdf-add-check-btn', 'doc-pdf-add-sign-btn']) {
const b = document.getElementById(id);
if (!b) continue;
const want = (mode === 'text' && id === 'doc-pdf-add-text-btn')
|| (mode === 'check' && id === 'doc-pdf-add-check-btn')
|| (mode === 'signature' && id === 'doc-pdf-add-sign-btn');
b.style.outline = want ? '2px solid var(--accent-primary, var(--red))' : '';
}
}
// Cache of signature data URLs by id, populated lazily as the PDF view
// renders inline signatures and as the user picks new ones.
const _sigCache = new Map();
// Mirror of Python _encode_name in src/pdf_form_doc.py — keep in sync.
// Percent-encode everything that's not A-Za-z0-9 _ . -
function _encodeFieldName(name) {
let out = '';
for (const ch of name || '') {
if (/[A-Za-z0-9_.\-]/.test(ch)) {
out += ch;
} else {
const enc = new TextEncoder().encode(ch);
for (const b of enc) out += '%' + b.toString(16).toUpperCase().padStart(2, '0');
}
}
return out;
}
// Proximity-based handle visibility — show ×/drag/resize handles whenever
// the cursor gets within ~30px of an annotation, not only when it's inside.
// Attached once to the pane; reads the current doc's refs at fire time.
let _pdfPaneProximityWired = false;
function _wirePdfPaneProximity(pane) {
if (_pdfPaneProximityWired || !pane) return;
_pdfPaneProximityWired = true;
let raf = 0;
const buffer = 30;
pane.addEventListener('mousemove', (ev) => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
const refs = _pdfPaneAnnotationsByDoc.get(activeDocId) || [];
for (const ref of refs) {
if (!ref || !ref.wrap || !ref._setHandlesVisible) continue;
const r = ref.wrap.getBoundingClientRect();
const dx = Math.max(r.left - ev.clientX, 0, ev.clientX - r.right);
const dy = Math.max(r.top - ev.clientY, 0, ev.clientY - r.bottom);
ref._setHandlesVisible(Math.hypot(dx, dy) <= buffer);
}
});
});
pane.addEventListener('mouseleave', () => {
const refs = _pdfPaneAnnotationsByDoc.get(activeDocId) || [];
for (const ref of refs) ref._setHandlesVisible && ref._setHandlesVisible(false);
});
}
async function _pdfResponseErrorMessage(res) {
const text = await res.text().catch(() => '');
try {
const data = JSON.parse(text);
if (typeof data?.detail === 'string') return data.detail;
if (data?.detail) return JSON.stringify(data.detail);
} catch (_) {}
return text || res.statusText || `HTTP ${res.status}`;
}
async function _renderPdfPane() {
const pane = document.getElementById('doc-pdf-view');
if (!pane || !activeDocId) return;
_wirePdfPaneProximity(pane);
const docId = activeDocId;
// Keep the save pill across re-renders by detaching/re-attaching it
const savedPill = document.getElementById('doc-pdf-save-pill');
pane.innerHTML = '
Loading PDF…
';
if (savedPill) pane.appendChild(savedPill);
let data;
try {
const res = await fetch(`${API_BASE}/api/document/${docId}/render-pages`);
if (!res.ok) throw new Error(await _pdfResponseErrorMessage(res));
data = await res.json();
} catch (e) {
pane.innerHTML = `
Failed to load PDF view: ${_escHtml(e.message || String(e))}
`;
if (savedPill) pane.appendChild(savedPill);
return;
}
if (docId !== activeDocId) return;
pane.innerHTML = '';
if (savedPill) pane.appendChild(savedPill);
const fieldRefs = [];
// Reset annotation refs for this doc before the page loop — we rebuild them
// page by page from the live markdown.
const annotationRefs = [];
_pdfPaneAnnotationsByDoc.set(docId, annotationRefs);
const liveMd = (docs.get(docId) && docs.get(docId).content) || '';
const allAnnotations = _parseAnnotations(liveMd);
// Recover the last-used line spacing from existing text annotations so the
// pref survives page reload, not just the in-memory life of this session.
if (!_pdfLastLineHeight.has(docId)) {
for (let i = allAnnotations.length - 1; i >= 0; i--) {
const a = allAnnotations[i];
if (a.kind === 'text' && a.lineHeight) {
_pdfLastLineHeight.set(docId, a.lineHeight);
break;
}
}
}
for (const page of data.pages) {
// Lock the wrap to the page's exact aspect ratio so percentage-positioned
// inputs stay aligned no matter how wide the panel is rendered.
const pageWrap = document.createElement('div');
pageWrap.style.cssText = `position:relative;margin:0 auto 16px auto;width:${page.width}px;max-width:calc(100% - 24px);aspect-ratio:${page.width} / ${page.height};background:#fff;box-shadow:0 4px 16px rgba(0,0,0,0.4);container-type:size;`;
const img = document.createElement('img');
img.src = `${API_BASE}/api/document/${docId}/page/${page.page}.png`;
img.style.cssText = 'display:block;width:100%;height:100%;user-select:none;-webkit-user-drag:none;pointer-events:none;';
img.draggable = false;
pageWrap.appendChild(img);
// Scale-aware overlay so inputs track if the page wrap shrinks below
// its natural width (we set width:page.width but max-width:100% caps it).
// Each field is positioned via percentages of the page rect.
for (const f of page.fields) {
const [x0, y0, x1, y1] = f.rect_px;
const wPct = ((x1 - x0) / page.width) * 100;
const hPct = ((y1 - y0) / page.height) * 100;
const lPct = (x0 / page.width) * 100;
const tPct = (y0 / page.height) * 100;
const isSig = f.type === 'signature' || /sign(?:ed|ature)/i.test((f.name || '') + ' ' + (f.label || ''));
let el;
const baseStyle = `position:absolute;left:${lPct}%;top:${tPct}%;width:${wPct}%;height:${hPct}%;box-sizing:border-box;font-family:inherit;`;
if (isSig) {
// Inline signature: click to pick / change. The selected signature
// ID is mirrored into the markdown bullet as `signature:` via
// the existing debounced save flow, which the export route reads.
el = document.createElement('div');
el.style.cssText = baseStyle + 'cursor:pointer;display:flex;align-items:center;justify-content:center;overflow:hidden;';
el.dataset.fieldName = f.name;
el.dataset.fieldType = 'signature';
// Parse pre-existing selection from value: `signature:` shape
const initialSigId = (typeof f.value === 'string' && f.value.startsWith('signature:'))
? f.value.slice('signature:'.length).trim() : '';
const renderSigUI = async (sigId) => {
el.innerHTML = '';
if (sigId) {
el.dataset.signatureId = sigId;
const img = document.createElement('img');
img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;';
// Look up the signature data URL via the saved-list cache or fetch
try {
if (!_sigCache.has(sigId)) {
const r = await fetch(`${API_BASE}/api/signatures`);
const data = await r.json();
for (const s of data.signatures || []) _sigCache.set(s.id, s.data_url);
}
const dataUrl = _sigCache.get(sigId);
if (dataUrl) img.src = dataUrl;
else throw new Error('not found');
el.appendChild(img);
el.style.border = '1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent)';
el.style.background = 'transparent';
} catch {
el.removeAttribute('data-signature-id');
renderSigUI('');
}
} else {
delete el.dataset.signatureId;
el.style.border = '1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent)';
el.style.background = 'color-mix(in srgb, var(--accent, var(--red)) 10%, transparent)';
const span = document.createElement('span');
span.style.cssText = 'color:var(--accent, var(--red));font-size:11px;';
span.textContent = 'Sign here';
el.appendChild(span);
}
};
el.addEventListener('click', async (ev) => {
ev.stopPropagation();
const sig = await signatureModule.pick();
if (sig) {
_sigCache.set(sig.id, sig.dataUrl);
await renderSigUI(sig.id);
_schedulePdfPaneSave();
}
});
renderSigUI(initialSigId);
} else if (f.type === 'checkbox') {
el = document.createElement('input');
el.type = 'checkbox';
el.checked = !!f.value;
el.style.cssText = baseStyle + 'cursor:pointer;';
} else if (f.type === 'choice' && (f.options || []).length) {
el = document.createElement('select');
const blank = document.createElement('option');
blank.value = ''; blank.textContent = '—';
el.appendChild(blank);
for (const opt of f.options) {
const o = document.createElement('option');
o.value = opt; o.textContent = opt;
if (opt === f.value) o.selected = true;
el.appendChild(o);
}
el.style.cssText = baseStyle + 'border:1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);background:rgba(255,255,255,0.85);font-size:11px;padding:0 2px;';
} else {
el = document.createElement('input');
el.type = 'text';
el.value = f.value == null ? '' : String(f.value);
// Pick a font-size that roughly fits the field height. Smaller
// multiplier than line-height to leave breathing room and match
// what AcroForm renderers typically use.
const fontPx = Math.max(8, Math.min(14, Math.round((y1 - y0) * 0.4)));
el.style.cssText = baseStyle + `border:1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);background:rgba(255,255,255,0.85);font-size:${fontPx}px;padding:0 2px;`;
}
if (!isSig) {
el.dataset.fieldName = f.name;
el.dataset.fieldType = f.type;
el.addEventListener('input', _schedulePdfPaneSave);
el.addEventListener('change', _schedulePdfPaneSave);
}
pageWrap.appendChild(el);
// Signature fields are also persisted via the markdown bullet — the
// click handler invokes _schedulePdfPaneSave directly after picking.
fieldRefs.push({ name: f.name, type: isSig ? 'signature' : f.type, el });
// Date-field shortcut: any text field whose name or label hints at
// a date gets a small "Today" button anchored to its right edge.
const isDate = f.type === 'text' && /\b(date|dated)\b/i.test(`${f.name} ${f.label}`);
if (isDate) {
const today = document.createElement('button');
today.type = 'button';
today.textContent = 'Today';
today.title = "Set to today's date";
today.style.cssText = `position:absolute;left:calc(${lPct}% + ${wPct}%);top:${tPct}%;height:${hPct}%;margin-left:4px;padding:0 6px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);background:rgba(255,255,255,0.95);color:var(--accent, var(--red));border-radius:3px;cursor:pointer;font-size:10px;line-height:1;white-space:nowrap;`;
today.addEventListener('click', () => {
const d = new Date();
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
el.value = `${dd}/${mm}/${yyyy}`;
_schedulePdfPaneSave();
});
pageWrap.appendChild(today);
}
}
// Freeform annotations for this page
for (const ann of allAnnotations) {
if (ann.page !== page.page) continue;
const built = _buildAnnotation(pageWrap, ann);
annotationRefs.push(built.ref);
}
// Click on empty page area drops a new annotation when a drop mode is
// active (toolbar buttons set the mode). Without a mode, clicking does
// nothing — keeps page interactions predictable so users don't get
// surprise boxes from stray clicks.
pageWrap.addEventListener('click', (ev) => {
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
const yPct = ((ev.clientY - rect.top) / rect.height) * 100;
// Default sizes per kind. Center the box on the click so the value
// shows up where the user pointed (text input width is wider than tall,
// so centering vertically is what matters).
const sizes = {
text: { w: 8, h: 2.5 },
check: { w: 2.5, h: 2.5 },
signature: { w: 22, h: 6 },
};
const size = sizes[_pdfDropMode] || sizes.text;
// Check stamps drop centered on the click (you point at the box you
// want to tick). Text + signature anchor top-left at the click so the
// first character lands exactly where the cursor was.
const centered = _pdfDropMode === 'check';
const x = Math.max(0, Math.min(100 - size.w, centered ? xPct - size.w / 2 : xPct));
const y = Math.max(0, Math.min(100 - size.h, centered ? yPct - size.h / 2 : yPct));
const ann = {
id: _newAnnotationId(),
page: page.page,
x, y, w: size.w, h: size.h,
value: _pdfDropMode === 'check' ? '[ ]' : '',
kind: _pdfDropMode,
// For text drops, inherit the doc's last-used line spacing so the
// user's "1.6" choice sticks across every new box they place.
lineHeight: _pdfDropMode === 'text' ? (_pdfLastLineHeight.get(docId) || 1.3) : undefined,
};
_pushPdfUndoSnapshot(docId);
const built = _buildAnnotation(pageWrap, ann);
annotationRefs.push(built.ref);
if (_pdfDropMode === 'text') {
built.ref.el.focus();
} else if (_pdfDropMode === 'signature') {
// Trigger the signature picker right away — users always want to
// pick the signature when they place the box.
built.ref.el.click();
}
_schedulePdfPaneSave();
// Mode stays armed — keep placing more until the user clicks the
// toolbar button again to turn it off.
});
pane.appendChild(pageWrap);
}
_pdfPaneFieldsByDoc.set(docId, fieldRefs);
}
// Render one annotation as a positioned wrapper with type-appropriate
// content (text input / checkbox / signature picker) plus delete and drag
// handles. Returns { ref } so the caller can track it for save.
function _buildAnnotation(pageWrap, ann) {
const kind = ann.kind || 'text';
const wrap = document.createElement('div');
wrap.className = 'pdf-annotation-wrap';
wrap.style.cssText = `position:absolute;left:${ann.x}%;top:${ann.y}%;width:${ann.w}%;height:${ann.h}%;box-sizing:border-box;z-index:2;`;
wrap.dataset.annId = ann.id;
wrap.dataset.annKind = kind;
let input;
if (kind === 'check') {
// Stamp-style checkmark drawn as an SVG so it scales with the box —
// a glyph at fixed font-size always over- or under-fills.
input = document.createElement('div');
input.style.cssText = `width:100%;height:100%;display:flex;align-items:center;justify-content:center;user-select:none;pointer-events:none;`;
input.innerHTML = ``;
} else if (kind === 'signature') {
input = document.createElement('div');
input.style.cssText = `width:100%;height:100%;box-sizing:border-box;border:1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;font-size:10px;color:var(--accent, var(--red));`;
input.textContent = (ann.value && ann.value.startsWith('signature:')) ? '' : 'Sign here';
input.dataset.signatureId = (ann.value && ann.value.startsWith('signature:')) ? ann.value.slice(10) : '';
} else {
// Multi-line text input. Browser resize disabled — we use the custom
// bottom-right handle for resizing so position metadata stays in sync.
// Font size uses cqh (container-query height) so the text scales with
// the rendered page when the doc panel resizes — keeps annotations
// visually anchored to the PDF instead of looking small/large after
// a fullscreen toggle.
input = document.createElement('textarea');
input.value = ann.value || '';
input.placeholder = 'Type…';
input.rows = 1;
input.spellcheck = false;
const lh = ann.lineHeight || 1.3;
input.style.cssText = `width:100%;height:100%;box-sizing:border-box;border:1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);font-family:inherit;font-size:1.5cqh;line-height:${lh};padding:1px 4px;color:#111;resize:none;overflow:auto;white-space:pre-wrap;`;
}
// Touch devices have no cursor, so the hover/proximity reveal never fires —
// there, show the handles permanently and make them finger-sized so the
// box edges are actually grabbable.
const _isTouch = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches;
const HS = _isTouch ? 28 : 20; // handle size (px)
// Sit the handles just outside the box — inner edge meets the corner (no
// gap, no overlap) so they don't cover the text you're typing but stay close.
const OFF = -HS;
const HIDE = _isTouch ? '' : 'none'; // initial display ('' = shown on touch)
// × delete button
const del = document.createElement('button');
del.type = 'button';
del.textContent = '✖';
del.title = 'Delete annotation';
del.style.cssText = `position:absolute;top:${OFF}px;right:${OFF}px;width:${HS}px;height:${HS}px;padding:0 0 0 1px;border:1px solid var(--accent, var(--red));background:#fff;color:var(--accent, var(--red));border-radius:50%;cursor:pointer;font-size:11px;line-height:1;display:${HIDE};font-weight:bold;touch-action:none;`;
// ☰ drag handle — same size as the × button.
const grip = document.createElement('div');
grip.title = 'Drag to move';
grip.textContent = '☰';
grip.style.cssText = `position:absolute;top:${OFF}px;left:${OFF}px;width:${HS}px;height:${HS}px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:3px;cursor:move;font-size:11px;line-height:${HS - 2}px;text-align:center;display:${HIDE};touch-action:none;`;
// ↘ resize handle — same size as the × button.
const resize = document.createElement('div');
resize.title = 'Drag to resize';
resize.style.cssText = `position:absolute;bottom:${OFF}px;right:${OFF}px;width:${HS}px;height:${HS}px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:3px;cursor:nwse-resize;display:${HIDE};touch-action:none;`;
resize.innerHTML = '';
let menuBtn = null;
if (kind === 'text') {
menuBtn = document.createElement('button');
menuBtn.type = 'button';
menuBtn.textContent = '…';
menuBtn.title = 'Text annotation options';
menuBtn.style.cssText = `position:absolute;bottom:${OFF}px;left:${OFF}px;width:${HS}px;height:${HS}px;padding:0;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:50%;cursor:pointer;font-size:15px;line-height:0.8;display:${HIDE};font-weight:bold;touch-action:none;`;
}
// Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back.
const _setHandlesVisible = (show) => {
const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
if (menuBtn) menuBtn.style.display = v;
};
if (!_isTouch) {
wrap.addEventListener('mouseenter', () => _setHandlesVisible(true));
wrap.addEventListener('mouseleave', () => _setHandlesVisible(false));
}
wrap.addEventListener('pointerdown', (ev) => {
if (ev.target === del || ev.target === grip || ev.target === resize || ev.target === menuBtn) return;
wrap.dataset.controlsDismissed = '0';
_setHandlesVisible(true);
});
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
ref.value = '✓';
} else if (kind === 'signature') {
const _renderSig = async (sigId) => {
input.innerHTML = '';
if (!sigId) {
input.dataset.signatureId = '';
input.style.background = 'color-mix(in srgb, var(--accent, var(--red)) 10%, transparent)';
input.style.border = '1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent)';
const span = document.createElement('span');
span.textContent = 'Sign here';
input.appendChild(span);
return;
}
input.dataset.signatureId = sigId;
try {
if (!_sigCache.has(sigId)) {
const r = await fetch(`${API_BASE}/api/signatures`);
const data = await r.json();
for (const s of data.signatures || []) _sigCache.set(s.id, s.data_url);
}
const dataUrl = _sigCache.get(sigId);
if (!dataUrl) throw new Error('not found');
const img = document.createElement('img');
img.src = dataUrl;
img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;';
input.appendChild(img);
input.style.background = 'transparent';
input.style.border = '1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent)';
} catch {
_renderSig('');
}
};
input.addEventListener('click', async (ev) => {
ev.stopPropagation();
const sig = await signatureModule.pick();
if (sig) {
_pushPdfUndoSnapshot();
_sigCache.set(sig.id, sig.dataUrl);
await _renderSig(sig.id);
ref.value = `signature:${sig.id}`;
_schedulePdfPaneSave();
}
});
// Render any pre-existing signature value
_renderSig(input.dataset.signatureId);
} else {
// Grow the wrap to fit typed content. Width grows for the longest line,
// height grows for total content height. Never shrinks — user-driven
// resizes (the corner handle) are preserved.
let _mirror = null;
const _autoGrow = () => {
const pageRect = pageWrap.getBoundingClientRect();
if (!pageRect.height || !pageRect.width) return;
// --- Width: measure the longest line via a hidden mirror div with
// the same typography as the textarea ---
if (!_mirror) {
_mirror = document.createElement('div');
_mirror.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font-family:inherit;padding:1px 4px;left:-9999px;top:-9999px;';
document.body.appendChild(_mirror);
}
const cs = window.getComputedStyle(input);
_mirror.style.fontSize = cs.fontSize;
_mirror.style.fontWeight = cs.fontWeight;
_mirror.style.fontFamily = cs.fontFamily;
_mirror.style.letterSpacing = cs.letterSpacing;
let widestPx = 0;
const lines = (input.value || input.placeholder || '').split('\n');
for (const line of lines) {
_mirror.textContent = line || ' ';
if (_mirror.offsetWidth > widestPx) widestPx = _mirror.offsetWidth;
}
const neededWPct = ((widestPx + 12) / pageRect.width) * 100;
if (neededWPct > ref.w) {
ref.w = Math.min(100 - ref.x, neededWPct);
wrap.style.width = ref.w + '%';
}
// --- Height: same trick as before, briefly let textarea fit content ---
const prev = input.style.height;
input.style.height = 'auto';
const neededHpx = input.scrollHeight + 4;
input.style.height = prev || '100%';
const neededHpct = (neededHpx / pageRect.height) * 100;
if (neededHpct > ref.h) {
ref.h = Math.min(100 - ref.y, neededHpct);
wrap.style.height = ref.h + '%';
}
};
input.addEventListener('input', () => {
if (wrap.dataset.textUndoCaptured !== '1') {
_pushPdfUndoSnapshot();
wrap.dataset.textUndoCaptured = '1';
}
ref.value = input.value;
_autoGrow();
_schedulePdfPaneSave();
});
input.addEventListener('change', () => {
ref.value = input.value;
_autoGrow();
_schedulePdfPaneSave();
});
input.addEventListener('focus', () => {
_pushPdfUndoSnapshot();
wrap.dataset.textUndoCaptured = '1';
});
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') input.blur();
});
// Initial fit in case the saved value is taller than the saved height
// (e.g. line-height was bumped up after the box was placed).
requestAnimationFrame(_autoGrow);
// Expose so the line-spacing slider can re-fit each annotation when
// the doc-wide spacing is changed.
ref._autoGrow = _autoGrow;
}
del.addEventListener('click', (ev) => {
ev.stopPropagation();
_pushPdfUndoSnapshot();
_removeAnnotation(ref);
});
// Drag to reposition. Coordinates are stored as percentages of the page
// wrap so they survive resizing.
grip.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
// Hide the × and resize handles while moving so they don't obscure the
// box — easier to see exactly where it lands. Restored on release.
del.style.display = 'none';
resize.style.display = 'none';
if (menuBtn) menuBtn.style.display = 'none';
const start = { mx: ev.clientX, my: ev.clientY, x: ref.x, y: ref.y };
const rect = pageWrap.getBoundingClientRect();
const onMove = (e) => {
const dxPct = ((e.clientX - start.mx) / rect.width) * 100;
const dyPct = ((e.clientY - start.my) / rect.height) * 100;
ref.x = Math.max(0, Math.min(100 - ref.w, start.x + dxPct));
ref.y = Math.max(0, Math.min(100 - ref.h, start.y + dyPct));
wrap.style.left = ref.x + '%';
wrap.style.top = ref.y + '%';
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
// Drag bottom-right corner to resize. Width/height stored as percentages.
resize.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
// Hide the × and move handles while resizing — clean view of the box edge.
del.style.display = 'none';
grip.style.display = 'none';
if (menuBtn) menuBtn.style.display = 'none';
const start = { mx: ev.clientX, my: ev.clientY, w: ref.w, h: ref.h };
const rect = pageWrap.getBoundingClientRect();
const onMove = (e) => {
const dwPct = ((e.clientX - start.mx) / rect.width) * 100;
const dhPct = ((e.clientY - start.my) / rect.height) * 100;
ref.w = Math.max(1, Math.min(100 - ref.x, start.w + dwPct));
ref.h = Math.max(0.8, Math.min(100 - ref.y, start.h + dhPct));
wrap.style.width = ref.w + '%';
wrap.style.height = ref.h + '%';
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
// Text options menu — opened from the floating … button so the spacing
// controls are not always visible while typing.
if (kind === 'text') {
const popover = document.createElement('div');
popover.className = 'pdf-annotation-text-menu';
popover.style.cssText = `position:absolute;bottom:${OFF + HS + 4}px;left:${OFF}px;display:none;background:#fff;border:1px solid var(--accent, var(--red));border-radius:4px;padding:6px 8px;box-shadow:0 2px 8px rgba(0,0,0,0.2);z-index:10;flex-direction:column;align-items:stretch;gap:6px;font-size:10px;color:#222;white-space:nowrap;`;
popover.innerHTML = `
Line spacing
`;
const slider = popover.querySelector('input[type="range"]');
const valInput = popover.querySelector('.lh-val');
const todayBtn = popover.querySelector('.pdf-ann-today');
const _applyLh = (v, fromSlider) => {
if (!Number.isFinite(v)) return;
if (popover.dataset.lhUndoCaptured !== '1') {
_pushPdfUndoSnapshot();
popover.dataset.lhUndoCaptured = '1';
}
v = Math.max(0.5, Math.min(5, v));
// Apply to every text annotation in the doc so spacing stays
// consistent — exports were "all over the place" because each box
// could have its own lh; treat it as a doc-level setting.
const allRefs = _pdfPaneAnnotationsByDoc.get(activeDocId) || [];
for (const r of allRefs) {
if (r.kind !== 'text') continue;
r.lineHeight = v;
if (r.el && r.el.style) r.el.style.lineHeight = String(v);
// Spacing change can push content past the box height — fire each
// ref's auto-grow so the wrap expands to fit the new line height.
if (typeof r._autoGrow === 'function') r._autoGrow();
}
ref.lineHeight = v;
input.style.lineHeight = String(v);
if (fromSlider) valInput.value = v.toFixed(2);
else slider.value = String(Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), v)));
_pdfLastLineHeight.set(activeDocId, v);
_schedulePdfPaneSave();
};
slider.addEventListener('input', () => _applyLh(parseFloat(slider.value), true));
valInput.addEventListener('input', () => _applyLh(parseFloat(valInput.value), false));
// Reject invalid typed values on blur — snap back to the live ref value.
valInput.addEventListener('blur', () => {
const v = parseFloat(valInput.value);
if (!Number.isFinite(v)) valInput.value = (ref.lineHeight || 1.3).toFixed(2);
popover.dataset.lhUndoCaptured = '0';
});
todayBtn.addEventListener('click', () => {
_pushPdfUndoSnapshot();
const d = new Date();
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
const text = `${dd}/${mm}/${yyyy}`;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.value = input.value.slice(0, start) + text + input.value.slice(end);
const next = start + text.length;
try { input.setSelectionRange(next, next); } catch (_) {}
ref.value = input.value;
if (typeof ref._autoGrow === 'function') ref._autoGrow();
_schedulePdfPaneSave();
input.focus({ preventScroll: true });
});
// Stop popover clicks from bubbling to pageWrap (would create new ann)
popover.addEventListener('mousedown', (e) => e.stopPropagation());
popover.addEventListener('click', (e) => e.stopPropagation());
menuBtn?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
popover.style.display = popover.style.display === 'flex' ? 'none' : 'flex';
});
wrap.appendChild(popover);
ref.lineHeight = ann.lineHeight || 1.3;
}
wrap.appendChild(input);
wrap.appendChild(del);
wrap.appendChild(grip);
wrap.appendChild(resize);
if (menuBtn) wrap.appendChild(menuBtn);
pageWrap.appendChild(wrap);
return { wrap, ref };
}
function _removeAnnotation(ref) {
if (!ref || !ref.wrap) return;
const docId = activeDocId;
const refs = _pdfPaneAnnotationsByDoc.get(docId) || [];
const idx = refs.indexOf(ref);
if (idx >= 0) refs.splice(idx, 1);
ref.wrap.remove();
_schedulePdfPaneSave();
}
// Prompt user for an instruction and ask the backend's VL pipeline to
// propose annotations for every blank/labeled spot on the PDF. Resulting
// annotations are appended into the doc's markdown and the PDF pane is
// re-rendered so the user can review / edit / drag / delete each one.
async function _aiFillAnnotations() {
const docId = activeDocId;
if (!docId) return;
const doc = docs.get(docId);
if (!doc) return;
const instruction = window.prompt(
'What should the AI fill in?\n(e.g. "My name is Jane Doe, address 123 Main St, dob 1990-01-15")'
);
if (!instruction || !instruction.trim()) return;
_setPdfSaveStatus('saving');
const btn = document.getElementById('doc-pdf-ai-fill-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Thinking…'; }
try {
const res = await fetch(`${API_BASE}/api/document/${docId}/ai-fill-annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruction: instruction.trim() }),
});
if (!res.ok) {
const t = await res.text().catch(() => res.statusText);
throw new Error(t || res.statusText);
}
const data = await res.json();
const proposed = (data && data.annotations) || [];
if (!proposed.length) {
_setPdfSaveStatus('idle');
if (uiModule && uiModule.showToast) uiModule.showToast('AI found nothing to fill');
return;
}
// Merge into markdown via the same _writeAnnotations path: parse current,
// append proposed (each gets a fresh id), persist, then re-render.
const existing = _parseAnnotations(doc.content || '');
const combined = existing.slice();
for (const a of proposed) {
combined.push({
id: _newAnnotationId(),
page: parseInt(a.page, 10) || 1,
x: Math.max(0, Math.min(100, parseFloat(a.x) || 0)),
y: Math.max(0, Math.min(100, parseFloat(a.y) || 0)),
w: Math.max(0.5, Math.min(100, parseFloat(a.w) || 22)),
h: Math.max(0.3, Math.min(100, parseFloat(a.h) || 3.5)),
value: String(a.value || ''),
});
}
const newMd = _writeAnnotations(doc.content || '', combined);
doc.content = newMd;
const ta = document.getElementById('doc-editor-textarea');
if (ta) ta.value = newMd;
const r2 = await fetch(`${API_BASE}/api/document/${docId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newMd }),
});
if (!r2.ok) {
const t = await r2.text().catch(() => r2.statusText);
throw new Error(t || r2.statusText);
}
_setPdfSaveStatus('saved');
if (uiModule && uiModule.showToast) uiModule.showToast(`AI added ${proposed.length} annotations`);
_renderPdfPane();
} catch (e) {
console.error('AI fill failed:', e);
_setPdfSaveStatus('error', `AI fill failed: ${e.message || e}`);
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'AI fill'; }
}
}
function _schedulePdfPaneSave() {
_setPdfSaveStatus('dirty');
if (_pdfPaneSaveTimer) clearTimeout(_pdfPaneSaveTimer);
_pdfPaneSaveTimer = setTimeout(() => _savePdfPaneToMarkdown(), 600);
}
function _setPdfSaveStatus(status, msg) {
const pill = document.getElementById('doc-pdf-save-pill');
if (!pill) return;
const palette = {
idle: { txt: '', bg: 'transparent', fg: 'transparent' },
dirty: { txt: 'Editing…', bg: 'var(--panel)', fg: 'var(--fg)' },
saving: { txt: 'Saving…', bg: 'var(--panel)', fg: 'var(--fg)' },
saved: { txt: 'Saved', bg: 'rgba(34,197,94,0.85)', fg: '#fff' },
error: { txt: msg || 'Save failed', bg: 'var(--red)', fg: 'var(--bg)' },
};
const p = palette[status] || palette.idle;
pill.textContent = p.txt;
pill.style.background = p.bg;
pill.style.color = p.fg;
pill.style.display = p.txt ? '' : 'none';
if (status === 'saved') {
setTimeout(() => {
if (pill.textContent === 'Saved') _setPdfSaveStatus('idle');
}, 1200);
}
}
async function _savePdfPaneToMarkdown(opts = {}) {
_pdfPaneSaveTimer = null;
const docId = activeDocId;
const fields = _pdfPaneFieldsByDoc.get(docId) || [];
const annotations = _pdfPaneAnnotationsByDoc.get(docId) || [];
if (!docId || (!fields.length && !annotations.length)) return false;
const doc = docs.get(docId);
if (!doc) return false;
let md = doc.content || '';
let changed = 0;
for (const ref of fields) {
// Server-side render percent-encodes everything outside [A-Za-z0-9_.-].
// Match that exactly so spaces / newlines / parens / commas / `?` in
// raw AcroForm names don't break the regex.
const encName = _encodeFieldName(ref.name);
const re = new RegExp(
`^(\\s*-\\s+)(.*?)(\\s*\\s*)$`,
'm'
);
const m = md.match(re);
if (!m) continue;
const body = m[2];
let newBody = body;
if (ref.type === 'checkbox') {
const mark = ref.el.checked ? '[x]' : '[ ]';
newBody = body.replace(/^\s*\[[ xX]\]/, mark);
} else if (ref.type === 'choice') {
const v = ref.el.value || '_(not selected)_';
newBody = body.replace(/(\][\s]*:[ ]*).*$/, `$1${v}`);
} else if (ref.type === 'signature') {
const sid = ref.el.dataset.signatureId || '';
const v = sid ? `signature:${sid}` : '_(unsigned)_';
newBody = body.replace(/(:\*\*[ ]*).*$/, `$1${v}`);
} else {
const v = ref.el.value === '' ? '_(empty)_' : ref.el.value;
newBody = body.replace(/(:\*\*[ ]*).*$/, `$1${v}`);
}
if (newBody !== body) {
md = md.replace(re, `${m[1]}${newBody}${m[3]}`);
changed++;
}
}
// Rewrite the freeform-annotations section from the live ref set so
// creates / edits / moves / deletes all persist in one shot.
md = _writeAnnotations(md, annotations.map(a => {
let value = '';
if (a.kind === 'check') {
value = '✓';
} else if (a.kind === 'signature') {
const sid = a.el && a.el.dataset && a.el.dataset.signatureId;
value = sid ? `signature:${sid}` : '';
} else {
value = (a.el && typeof a.el.value === 'string') ? a.el.value : '';
}
return {
id: a.id, page: a.page, x: a.x, y: a.y, w: a.w, h: a.h,
kind: a.kind || 'text',
lineHeight: a.lineHeight || 1.3,
value,
};
}));
if (md === doc.content) {
_setPdfSaveStatus('idle');
return true;
}
doc.content = md;
const ta = document.getElementById('doc-editor-textarea');
if (ta) ta.value = md;
_setPdfSaveStatus('saving');
try {
const res = await fetch(`${API_BASE}/api/document/${docId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: md }),
keepalive: !!opts.keepalive,
});
if (!res.ok) {
const t = await res.text().catch(() => res.statusText);
_setPdfSaveStatus('error', `Save failed: ${res.status}`);
console.warn('PDF-pane save HTTP error:', res.status, t);
return false;
}
_setPdfSaveStatus('saved');
return true;
} catch (e) {
_setPdfSaveStatus('error', e.message || 'Save failed');
console.warn('PDF-pane save failed:', e);
return false;
}
}
// Flush any pending debounced save before navigating away
window.addEventListener('beforeunload', () => {
if (_pdfPaneSaveTimer) {
clearTimeout(_pdfPaneSaveTimer);
_savePdfPaneToMarkdown({ keepalive: true });
}
});
async function _refreshPdfPreviewIframe() {
// Re-render the pane from the backend's current parsed values.
// Flush any debounced user edit first so we don't clobber it.
const pane = document.getElementById('doc-pdf-view');
if (!pane || !activeDocId) return;
if (pane.style.display === 'none') return;
if (_pdfPaneSaveTimer) {
clearTimeout(_pdfPaneSaveTimer);
await _savePdfPaneToMarkdown();
}
_renderPdfPane();
}
async function _setPdfViewActive(active) {
const pane = document.getElementById('doc-pdf-view');
const wrap = document.getElementById('doc-editor-wrap');
const btn = document.getElementById('doc-pdf-view-btn');
if (!pane || !wrap) return;
if (active) {
_pdfViewState.set(activeDocId, true);
wrap.style.display = 'none';
pane.style.display = '';
_renderPdfPane();
btn?.classList.add('active');
} else {
// Flush any pending debounced edit before tearing down the field refs.
if (_pdfPaneSaveTimer) {
clearTimeout(_pdfPaneSaveTimer);
await _savePdfPaneToMarkdown();
}
_pdfViewState.set(activeDocId, false);
pane.style.display = 'none';
// Preserve the save pill across renders
const savedPill = document.getElementById('doc-pdf-save-pill');
pane.innerHTML = '';
if (savedPill) pane.appendChild(savedPill);
_pdfPaneFieldsByDoc.delete(activeDocId);
_pdfPaneAnnotationsByDoc.delete(activeDocId);
wrap.style.display = '';
btn?.classList.remove('active');
}
}
// Hide the top header bar when nothing in it is visible. With Undo + the type
// picker moved to the footer, a plain doc on mobile would otherwise show an
// empty bar (the "second footer"). Reflow-free (reads inline display only) so
// it's safe to call from _syncHeaderActions on every stream patch. On desktop
// the bar always shows (it still hosts Fullscreen + the version badge); on
// mobile it shows only when a contextual control is active.
function _syncHeaderBarVisibility() {
const hdr = document.getElementById('doc-editor-actions');
if (!hdr) return;
// Email docs hide the whole header (they use their own send footer) — never
// resurrect it here.
if (docs.get(activeDocId)?.language === 'email') { hdr.style.display = 'none'; return; }
const vis = (id) => {
const e = document.getElementById(id);
if (!e || !e.parentElement) return false;
// Only count items still LIVING in the header itself — the runtime
// rearrangement (~line 3217) moves several buttons into the footer, and
// we don't want a button parked elsewhere to keep this top row alive.
if (!hdr.contains(e)) return false;
return e.style.display !== 'none';
};
// Hide the whole header when nothing visible lives here anymore. Without
// this every desktop view rendered an empty doc-editor-header above the
// real action footer — a duplicate row.
const visible = vis('doc-stream-indicator')
|| vis('doc-version-badge')
|| vis('doc-export-pdf-btn')
|| vis('doc-pdf-view-btn');
hdr.style.display = visible ? '' : 'none';
}
function _syncHeaderActions() {
const actionBtn = document.getElementById('doc-header-preview-btn');
const exportBtn = document.getElementById('doc-export-pdf-btn');
const pdfViewBtn = document.getElementById('doc-pdf-view-btn');
const pdfPane = document.getElementById('doc-pdf-view');
const langSelect = document.getElementById('doc-language-select');
const live = document.getElementById('doc-editor-textarea')?.value
|| docs.get(activeDocId)?.content
|| '';
const isForm = _isFormBackedDoc(live);
// Footer main button: for a doc opened from an email attachment, morph the
// Copy button into "Reply" (send the filled file back to the sender via the
// signed-reply flow). Otherwise it's the normal Copy action. The click
// handler branches on data-mode.
const _copyBtn = document.getElementById('doc-footer-copy-btn');
if (_copyBtn) {
const _ad = docs.get(activeDocId);
const _replyable = !!(_ad && _ad.sourceEmailUid && _ad.sourceEmailFolder);
if (_replyable && _copyBtn.dataset.mode !== 'reply') {
_copyBtn.dataset.mode = 'reply';
_copyBtn.title = 'Reply to the sender with this filled file attached';
_copyBtn.innerHTML = 'Attach';
} else if (!_replyable && _copyBtn.dataset.mode !== 'copy') {
_copyBtn.dataset.mode = 'copy';
_copyBtn.title = 'Copy document';
_copyBtn.innerHTML = 'Copy';
}
}
// Standalone Export PDF / PDF-toggle icon buttons are retired — for a
// form-backed doc the language selector itself toggles between
// "pdf" (rendered view) and "markdown" (source view).
if (exportBtn) exportBtn.style.display = 'none';
if (pdfViewBtn) pdfViewBtn.style.display = 'none';
if (true) {
const explicit = _pdfViewState.get(activeDocId);
const active = isForm && explicit !== false;
// Sync the language select's displayed value to the current view.
if (isForm && langSelect) {
const want = active ? 'pdf' : 'markdown';
if (langSelect.value !== want) langSelect.value = want;
}
if (pdfPane) {
if (active) {
if (pdfPane.style.display === 'none') {
const wrap = document.getElementById('doc-editor-wrap');
if (wrap) wrap.style.display = 'none';
pdfPane.style.display = '';
_renderPdfPane();
}
} else if (pdfPane.style.display !== 'none') {
pdfPane.style.display = 'none';
pdfPane.innerHTML = '';
const wrap = document.getElementById('doc-editor-wrap');
if (wrap) wrap.style.display = '';
}
}
}
if (!actionBtn) return;
const lang = (document.getElementById('doc-language-select')?.value || '').toLowerCase();
const canPreview = ['markdown', 'csv'].includes(lang) || _isRenderLang(lang);
const canRun = ['javascript', 'js', 'python', 'py', 'bash', 'sh', 'shell', 'zsh'].includes(lang);
const _eyeIco = '';
const _penIco = '';
const _playIco = '';
const _codeIco = '';
// Check active states
const _mdPreview = document.getElementById('doc-md-preview');
const _csvPreview = document.getElementById('doc-csv-preview');
const _htmlPreview = document.getElementById('doc-html-preview');
const _outputPanel = document.getElementById('doc-run-output');
const _mdActive = _mdPreview && _mdPreview.style.display !== 'none';
const _csvActive = _csvPreview && _csvPreview.style.display !== 'none';
const _htmlActive = _htmlPreview && _htmlPreview.style.display !== 'none';
const _outputActive = _outputPanel && _outputPanel.style.display !== 'none';
let show = false;
actionBtn.classList.remove('active');
// The markdown Edit/Preview toggle is a two-icon switch; other modes use
// the single dynamic preview button.
const mdToggle = document.getElementById('doc-md-view-toggle');
if (mdToggle) mdToggle.style.display = (lang === 'markdown') ? 'inline-flex' : 'none';
const renderToggle = document.getElementById('doc-render-view-toggle');
if (renderToggle) {
renderToggle.style.display = _hasViewToggle(lang) ? 'inline-flex' : 'none';
// Swap the "run" side's icon to match what the language actually does:
// CSV → 4-quadrant grid (table view)
// HTML / SVG / XML → eye (rendered preview)
// Python / JS / TS / bash → play triangle (run code)
const runBtn = renderToggle.querySelector('[data-renderview="run"]');
if (runBtn) {
let icon, title;
if (lang === 'csv') {
icon = '';
title = 'Table view';
} else if (_isRenderLang(lang)) {
icon = '';
title = 'Preview';
} else {
icon = '';
title = 'Run';
}
if (runBtn.dataset.lastIcon !== lang) {
runBtn.innerHTML = icon;
runBtn.title = title;
runBtn.dataset.lastIcon = lang;
}
}
// Swap the "code" side's icon too — CSV's "code" really means "edit
// the underlying spreadsheet text", so a pencil reads better than the
// > brackets used for actual code.
const codeBtn = renderToggle.querySelector('[data-renderview="code"]');
if (codeBtn) {
const codeIco = (lang === 'csv')
? ''
: '';
const codeTitle = (lang === 'csv') ? 'Edit' : 'Edit code';
if (codeBtn.dataset.lastIcon !== lang) {
codeBtn.innerHTML = codeIco;
codeBtn.title = codeTitle;
codeBtn.dataset.lastIcon = lang;
}
}
// Reflect which side is currently active so the toggle shows the same
// visual feedback markdown's Edit/Preview switch does (background tint
// + the "punch" pop animation from .md-view-toggle .md-view-opt.active).
// For CSV the run side = table view; for HTML/SVG/XML = iframe preview;
// for runnable langs = output panel open.
let _viewActive = false;
if (lang === 'csv') _viewActive = _csvActive;
else if (_isRenderLang(lang)) _viewActive = _htmlActive;
else _viewActive = _outputActive;
const _codeBtn2 = renderToggle.querySelector('[data-renderview="code"]');
const _runBtn2 = renderToggle.querySelector('[data-renderview="run"]');
_codeBtn2?.classList.toggle('active', !_viewActive);
_runBtn2?.classList.toggle('active', _viewActive);
}
if (lang === 'markdown') {
show = false;
if (mdToggle) {
mdToggle.querySelector('[data-mdview="edit"]')?.classList.toggle('active', !_mdActive);
mdToggle.querySelector('[data-mdview="preview"]')?.classList.toggle('active', _mdActive);
}
} else if (lang === 'csv') {
show = true;
actionBtn.innerHTML = _csvActive ? _penIco : '⊞';
actionBtn.title = _csvActive ? 'Edit' : 'Table View';
if (_csvActive) actionBtn.classList.add('active');
} else if (_isRenderLang(lang)) {
// SVG/HTML/XML use the segmented Code > | Run ▶ light-switch toggle
// (like markdown's edit/preview switch) instead of the single button.
show = false;
if (renderToggle) {
renderToggle.querySelector('[data-renderview="code"]')?.classList.toggle('active', !_htmlActive);
renderToggle.querySelector('[data-renderview="run"]')?.classList.toggle('active', _htmlActive);
}
} else if (canRun) {
show = true;
actionBtn.innerHTML = _outputActive ? _codeIco : _playIco;
actionBtn.title = _outputActive ? 'Hide output' : 'Run';
if (_outputActive) actionBtn.classList.add('active');
}
// The unified segmented Code/Run-or-View toggle (`#doc-render-view-toggle`)
// covers CSV / Python / JS / bash / HTML / SVG / XML. When it's shown,
// suppress the single morph button to avoid two redundant controls.
if (_hasViewToggle(lang)) show = false;
actionBtn.style.display = show ? '' : 'none';
// Now that the contextual buttons' visibility is settled, collapse the bar
// if it ended up empty (the common plain-doc-on-mobile case).
_syncHeaderBarVisibility();
}
// ── Email document type helpers ──
function _parseEmailHeader(content) {
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: content || '' };
if (!content) return empty;
const parts = content.split(/\n---\n/);
if (parts.length < 2) return empty;
const header = parts[0];
const body = parts.slice(1).join('\n---\n');
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: body };
for (const line of header.split('\n')) {
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):\s*(.*)$/i);
if (m) {
let key = m[1].toLowerCase();
if (key === 'in-reply-to') key = 'inReplyTo';
else if (key === 'x-source-uid') key = 'sourceUid';
else if (key === 'x-source-folder') key = 'sourceFolder';
else if (key === 'x-attachments') {
fields.attachments = m[2].trim().split('|').map(a => {
const [index, filename, size] = a.split(':');
return { index: parseInt(index), filename, size: parseInt(size) };
});
continue;
}
fields[key] = m[2].trim();
}
}
return fields;
}
function _buildEmailContent(to, subject, inReplyTo, references, body, sourceUid, sourceFolder, cc, bcc) {
let header = `To: ${to}`;
if (cc) header += `\nCc: ${cc}`;
if (bcc) header += `\nBcc: ${bcc}`;
header += `\nSubject: ${subject}`;
if (inReplyTo) header += `\nIn-Reply-To: ${inReplyTo}`;
if (references) header += `\nReferences: ${references}`;
if (sourceUid) header += `\nX-Source-UID: ${sourceUid}`;
if (sourceFolder) header += `\nX-Source-Folder: ${sourceFolder}`;
return header + '\n---\n' + body;
}
// ── WYSIWYG email body helpers ──
function _emailBodyToHtml(text) {
const t = (text || '').trim();
if (!t) return '';
// If it already contains a formatting/structural HTML tag, it's a saved
// WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
// rich body often starts with plain text, e.g. "Hi there".)
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
try { return markdownModule.mdToHtml(text); }
catch (_) {
const d = document.createElement('div'); d.textContent = text;
return d.innerHTML.replace(/\n/g, ' ');
}
}
// Mirror the rich body's plain text into the hidden textarea so the existing
// send / draft / change-detection plumbing (which reads the textarea) stays
// valid. The rich body's HTML is read separately on send (body_html).
function _syncEmailRichbody(rich) {
const ta = document.getElementById('doc-editor-textarea');
if (!ta) return;
ta.value = rich.innerText;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
function _wireEmailRichbody(rich) {
if (rich._wired) { _syncEmailRichbody(rich); return; }
rich._wired = true;
rich.addEventListener('input', () => _syncEmailRichbody(rich));
// Highlight toolbar buttons (B / I / S, headings, lists) when the caret
// sits inside formatted text. queryCommandState reflects the live
// selection — we just translate that into .is-active classes the CSS
// already understands.
const syncActive = () => {
if (!rich.isConnected || rich.style.display === 'none') return;
// Only sync when focus is inside the rich body — otherwise selection
// outside it (e.g. clicking the toolbar itself) gives misleading state.
if (!rich.contains(document.activeElement) && document.activeElement !== rich) return;
const tb = document.getElementById('doc-md-toolbar');
if (!tb) return;
const set = (sel, on) => { const b = tb.querySelector(sel); if (b) b.classList.toggle('is-active', !!on); };
try {
set('[data-md="bold"]', document.queryCommandState('bold'));
set('[data-md="italic"]', document.queryCommandState('italic'));
set('[data-md="strike"]', document.queryCommandState('strikeThrough'));
} catch (_) {}
// Block-level: heading / list dropdown toggles read their active state
// from the current block tag.
const cur = _currentBlockTag(rich);
const hBtn = tb.querySelector('[data-dd="heading"]');
if (hBtn) hBtn.classList.toggle('is-active', cur === 'h1' || cur === 'h2' || cur === 'h3');
try {
const inList = document.queryCommandState('insertOrderedList') || document.queryCommandState('insertUnorderedList');
const lBtn = tb.querySelector('[data-dd="list"]');
if (lBtn) lBtn.classList.toggle('is-active', !!inList);
} catch (_) {}
};
rich.addEventListener('keyup', syncActive);
rich.addEventListener('mouseup', syncActive);
rich.addEventListener('focus', syncActive);
rich.addEventListener('input', syncActive);
// selectionchange fires on the document; filter to selections inside rich.
document.addEventListener('selectionchange', () => {
const sel = window.getSelection();
if (sel && sel.rangeCount && rich.contains(sel.anchorNode)) syncActive();
});
rich._syncActive = syncActive;
}
function _emailRichbodyActive() {
const r = document.getElementById('doc-email-richbody');
return r && r.style.display !== 'none' ? r : null;
}
function _stripEmailReplyQuoteText(text) {
const original = String(text || '');
if (!original) return { body: '', stripped: false };
const lines = original.split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
if (quoteIdx <= 0) return { body: original.trim(), stripped: false };
const body = lines.slice(0, quoteIdx).join('\n').trim();
return { body, stripped: !!body };
}
function _emailReplyOwnText(text) {
return _stripEmailReplyQuoteText(text).body;
}
function _setEmailBodyText(textarea, value) {
if (!textarea) return;
textarea.value = value || '';
syncHighlighting();
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value);
}
async function _streamEmailBodyText(textarea, value) {
if (!textarea) return;
const finalText = String(value || '');
const maxFrames = 90;
const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames));
textarea.value = '';
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = '';
for (let i = 0; i < finalText.length; i += chunk) {
const next = finalText.slice(0, i + chunk);
textarea.value = next;
if (rich) rich.innerHTML = _emailBodyToHtml(next);
await new Promise(resolve => requestAnimationFrame(resolve));
}
_setEmailBodyText(textarea, finalText);
}
function _focusEmailBodyEnd() {
const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea');
if (!target) return;
target.focus();
if (target.isContentEditable) {
const range = document.createRange();
range.selectNodeContents(target);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
} else if (typeof target.setSelectionRange === 'function') {
const len = target.value.length;
target.setSelectionRange(len, len);
}
}
function _showEmailFields(doc) {
const emailHeader = document.getElementById('doc-email-header');
const emailActions = document.getElementById('doc-email-actions');
// Show MD toolbar for email too (B, I, etc.)
const mdToolbar = document.getElementById('doc-md-toolbar');
if (mdToolbar) {
mdToolbar.style.display = '';
if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow);
}
// Hide toolbar items that have no clean WYSIWYG equivalent in email (Code).
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; });
if (emailHeader) emailHeader.style.display = '';
if (emailActions) emailActions.style.display = '';
// Emails have their own complete footer (Close / More / Send), so hide the
// generic documents action bar AND the generic bottom footer. The TYPE
// picker is the exception — relocate it into the email footer so the
// type-switching affordance is in the same footer slot across all docs.
const docActions = document.getElementById('doc-editor-actions');
if (docActions) docActions.style.display = 'none';
const docFooter = document.getElementById('doc-actions-footer');
if (docFooter) docFooter.style.display = 'none';
if (emailActions) {
const _lang = document.getElementById('doc-language-select');
const _sendSplit = emailActions.querySelector('.email-send-split');
if (_lang && _sendSplit) emailActions.insertBefore(_lang, _sendSplit);
}
// Colored system-emoji font for email compose
document.getElementById('doc-editor-textarea')?.classList.add('email-mode');
document.getElementById('doc-editor-code')?.classList.add('email-mode');
document.getElementById('doc-editor-highlight')?.classList.add('email-mode');
const fields = _parseEmailHeader(doc.content || '');
const toInput = document.getElementById('doc-email-to');
const subjectInput = document.getElementById('doc-email-subject');
const inReplyTo = document.getElementById('doc-email-in-reply-to');
const refs = document.getElementById('doc-email-references');
const textarea = document.getElementById('doc-editor-textarea');
if (toInput) toInput.value = fields.to;
if (subjectInput) subjectInput.value = fields.subject;
if (subjectInput && !subjectInput._emailTabBodyBound) {
subjectInput._emailTabBodyBound = true;
subjectInput.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
_focusEmailBodyEnd();
}
});
}
if (inReplyTo) inReplyTo.value = fields.inReplyTo;
if (refs) refs.value = fields.references;
const sourceUid = document.getElementById('doc-email-source-uid');
const sourceFolder = document.getElementById('doc-email-source-folder');
if (sourceUid) sourceUid.value = fields.sourceUid || '';
if (sourceFolder) sourceFolder.value = fields.sourceFolder || '';
// Show/hide unread button only if we have a source UID (came from inbox)
const unreadBtn = document.getElementById('doc-email-unread-btn');
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
// Render attachment chips
const attDiv = document.getElementById('doc-email-attachments');
if (attDiv) {
attDiv.innerHTML = '';
if (fields.attachments && fields.attachments.length > 0 && fields.sourceUid) {
attDiv.style.display = '';
for (const att of fields.attachments) {
const isPdf = (att.filename || '').toLowerCase().endsWith('.pdf');
const sizeKb = att.size > 0 ? `${Math.round(att.size / 1024)} KB` : '';
const chipHtml = `${_escHtml(att.filename)}${sizeKb}`;
// Helper: swap chip content for a whirlpool spinner while busy.
const _withSpinner = async (chip, fn) => {
if (chip.dataset.loading === '1') return;
chip.dataset.loading = '1';
const orig = chip.innerHTML;
chip.innerHTML = '';
const sp = spinnerModule.createWhirlpool(14);
sp.style.marginRight = '6px';
chip.appendChild(sp);
const lbl = document.createElement('span');
lbl.textContent = att.filename;
chip.appendChild(lbl);
try { await fn(); }
finally { chip.dataset.loading = ''; chip.innerHTML = orig; }
};
if (isPdf) {
// PDF: open in the in-app PDF viewer as a new doc tab
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'email-attachment-chip email-attachment-chip-pdf';
// Full filename on hover — chip ellipsis-truncates long names.
chip.title = att.filename;
chip.innerHTML = chipHtml;
chip.addEventListener('click', () => _withSpinner(chip, async () => {
try {
const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX');
const res = await fetch(`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`, { method: 'POST' });
const data = await res.json();
if (data.doc_id) {
await loadDocument(data.doc_id);
} else if (uiModule) {
uiModule.showError(data.error || 'Failed to open PDF');
window.open(`${API_BASE}/api/email/attachment/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`, '_blank');
}
} catch (e) {
console.error('Open PDF attachment failed:', e);
if (uiModule) uiModule.showError('Failed to open PDF');
}
}));
attDiv.appendChild(chip);
} else {
// Non-PDF: download via fetch+blob+anchor — browser-native download
// with target=_blank was unreliable in some browsers (the click did
// nothing). The blob path forces a real Save dialog every time.
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'email-attachment-chip';
// Full filename on hover for the chip ellipsis-truncated label.
chip.title = `Download ${att.filename}`;
chip.innerHTML = chipHtml;
chip.addEventListener('click', () => _withSpinner(chip, async () => {
try {
const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX');
const res = await fetch(`${API_BASE}/api/email/attachment/${encodeURIComponent(fields.sourceUid)}/${att.index}?folder=${folderQs}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = att.filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
console.error('Download attachment failed:', e);
if (uiModule) uiModule.showError('Download failed: ' + e.message);
}
}));
attDiv.appendChild(chip);
}
}
} else {
attDiv.style.display = 'none';
}
}
if (textarea) {
textarea.value = fields.body;
// Store original body for change detection on close
if (doc) doc._originalBody = fields.body;
syncHighlighting();
}
// WYSIWYG: swap the source editor for the rich body and render the markdown.
// The textarea above stays as the plain-text mirror (kept in sync below) so
// send / draft / change-detection still read it.
const _rich = document.getElementById('doc-email-richbody');
const _srcWrap = document.getElementById('doc-editor-wrap');
if (_rich && _srcWrap) {
_srcWrap.style.display = 'none';
_rich.style.display = '';
_rich.innerHTML = _emailBodyToHtml(fields.body);
_wireEmailRichbody(_rich);
setTimeout(() => {
try {
const _isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0;
if (!_isTouch) _rich.focus();
_rich.scrollTop = 0;
} catch (_) {}
}, 50);
}
// Render compose attachments (if any uploaded for this doc)
_renderComposeAttachments();
// Populate CC/BCC from parsed header, show rows if populated
const ccRow = document.getElementById('doc-email-cc-row');
const bccRow = document.getElementById('doc-email-bcc-row');
const ccToggle = document.getElementById('doc-email-show-cc');
const ccInput = document.getElementById('doc-email-cc');
const bccInput = document.getElementById('doc-email-bcc');
if (ccInput) ccInput.value = fields.cc || '';
if (bccInput) bccInput.value = fields.bcc || '';
const hasCcBcc = !!(fields.cc || fields.bcc);
if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none';
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
}
async function _uploadComposeFiles(files) {
const list = Array.from(files || []);
if (list.length === 0) return;
const doc = docs.get(activeDocId);
if (!doc) return;
if (doc.language !== 'email') return;
if (!doc._composeAtts) doc._composeAtts = [];
for (const file of list) {
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API_BASE}/api/email/compose-upload`, {
method: 'POST',
body: fd,
});
const data = await res.json();
if (data.success) {
doc._composeAtts.push({
token: data.token,
filename: data.filename,
size: data.size,
});
} else {
if (uiModule) uiModule.showError(`Failed to upload ${file.name}: ${data.error || ''}`);
}
} catch (err) {
if (uiModule) uiModule.showError(`Failed to upload ${file.name}`);
}
}
_renderComposeAttachments();
}
async function _handleAttachUpload(e) {
const files = e.target.files;
e.target.value = ''; // reset for next upload
await _uploadComposeFiles(files);
}
function _renderComposeAttachments() {
const container = document.getElementById('doc-email-compose-atts');
if (!container) return;
const doc = docs.get(activeDocId);
const atts = doc?._composeAtts || [];
if (atts.length === 0) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = '';
container.innerHTML = '';
for (const att of atts) {
const chip = document.createElement('span');
chip.className = 'email-compose-chip';
const sizeKb = att.size > 0 ? `${Math.round(att.size / 1024)} KB` : '';
chip.innerHTML = `
${_escHtml(att.filename)}${sizeKb}
`;
chip.querySelector('.compose-chip-remove').addEventListener('click', async (e) => {
e.stopPropagation();
try {
await fetch(`${API_BASE}/api/email/compose-upload/${encodeURIComponent(att.token)}`, { method: 'DELETE' });
} catch (_) {}
const d = docs.get(activeDocId);
if (d) d._composeAtts = d._composeAtts.filter(a => a.token !== att.token);
_renderComposeAttachments();
});
container.appendChild(chip);
}
}
// Split a To/Cc/Bcc text field into recipients + the in-progress fragment
// the user is currently typing (after the last comma). Returns a tuple so
// we can show suggestions for just the fragment without disturbing the
// already-confirmed recipients.
function _splitRecipientsAndFragment(rawValue) {
const cut = (rawValue || '').lastIndexOf(',');
if (cut < 0) return { confirmed: '', fragment: (rawValue || '').trimStart() };
return {
confirmed: rawValue.slice(0, cut + 1).trimStart(),
fragment: rawValue.slice(cut + 1).trimStart(),
};
}
// Replace the in-progress fragment in `input` with the chosen email,
// append ", " so the user can type the next recipient immediately, then
// hide the suggestion dropdown.
function _commitRecipient(input, sugg, email) {
if (!input) return;
const { confirmed } = _splitRecipientsAndFragment(input.value);
// Preserve a single trailing space between commas for readability.
const head = confirmed ? confirmed.replace(/\s+$/, '') + ' ' : '';
input.value = head + email + ', ';
if (sugg) sugg.style.display = 'none';
input.focus();
// Caret to end so the next keystroke lands in the right place.
const end = input.value.length;
try { input.setSelectionRange(end, end); } catch (_) {}
}
// Search contacts for an autocomplete dropdown. `input` is the To/Cc/Bcc
// text field, `sugg` is its sibling .email-autocomplete div. Suggestions
// are scoped to the LAST comma-separated fragment so already-entered
// recipients aren't disturbed.
async function _searchContacts(input, sugg) {
if (!input || !sugg) return;
const { fragment } = _splitRecipientsAndFragment(input.value);
if (!fragment || fragment.length < 1) { sugg.style.display = 'none'; return; }
try {
const res = await fetch(`${API_BASE}/api/contacts/search?q=${encodeURIComponent(fragment)}`);
const data = await res.json();
if (!data.results || data.results.length === 0) {
sugg.style.display = 'none';
return;
}
// Already-entered emails in this field — skip in the dropdown so
// users don't accidentally add the same person twice.
const already = new Set(
(input.value || '').split(',').map(s => {
const m = s.match(/<([^>]+)>/);
return (m ? m[1] : s).trim().toLowerCase();
}).filter(Boolean)
);
sugg.innerHTML = '';
let count = 0;
for (const c of data.results) {
for (const em of (c.emails || [])) {
if (already.has(em.toLowerCase())) continue;
const item = document.createElement('div');
item.className = 'contact-suggestion';
item.innerHTML = `${_escHtml(c.name)}${_escHtml(em)}`;
// mousedown fires before blur so the click doesn't get lost
item.addEventListener('mousedown', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
item.addEventListener('click', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
sugg.appendChild(item);
count += 1;
}
}
if (count === 0) { sugg.style.display = 'none'; return; }
// Auto-highlight first suggestion so Enter accepts it.
const first = sugg.querySelector('.contact-suggestion');
if (first) first.classList.add('active');
sugg.style.display = '';
} catch (e) {
sugg.style.display = 'none';
}
}
// Bind input/keydown/blur for a recipient field so it gets the same
// autocomplete-and-commit behavior. Used by To/Cc/Bcc.
function _wireRecipientAutocomplete(inputId, suggId) {
const input = document.getElementById(inputId);
const sugg = document.getElementById(suggId);
if (!input || !sugg) return;
let timer = null;
input.addEventListener('input', () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => _searchContacts(input, sugg), 150);
});
input.addEventListener('blur', () => {
setTimeout(() => { sugg.style.display = 'none'; }, 200);
});
input.addEventListener('keydown', (e) => {
const open = sugg.style.display !== 'none';
const items = open ? sugg.querySelectorAll('.contact-suggestion') : [];
const active = open ? sugg.querySelector('.contact-suggestion.active') : null;
let idx = active ? Array.from(items).indexOf(active) : -1;
if (open && e.key === 'ArrowDown') {
e.preventDefault();
idx = Math.min(items.length - 1, idx + 1);
items.forEach(it => it.classList.remove('active'));
if (items[idx]) items[idx].classList.add('active');
} else if (open && e.key === 'ArrowUp') {
e.preventDefault();
idx = Math.max(0, idx - 1);
items.forEach(it => it.classList.remove('active'));
if (items[idx]) items[idx].classList.add('active');
} else if (e.key === 'Enter') {
// If a suggestion is highlighted, commit it. Otherwise — if the
// current fragment already looks like a complete email — commit
// the raw text so users who type a brand-new address don't have
// to add the comma themselves.
if (active) {
e.preventDefault();
const em = active.querySelector('.contact-email')?.textContent?.trim();
if (em) _commitRecipient(input, sugg, em);
} else {
const { fragment } = _splitRecipientsAndFragment(input.value);
if (/^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/.test(fragment.trim())) {
e.preventDefault();
_commitRecipient(input, sugg, fragment.trim());
}
}
} else if (e.key === 'Tab' && active) {
e.preventDefault();
const em = active.querySelector('.contact-email')?.textContent?.trim();
if (em) _commitRecipient(input, sugg, em);
} else if (e.key === 'Escape') {
sugg.style.display = 'none';
} else if (e.key === ',' || (e.key === ' ' && input.value.trim().endsWith(','))) {
// Typing a comma directly also accepts a highlighted suggestion.
if (active) {
e.preventDefault();
const em = active.querySelector('.contact-email')?.textContent?.trim();
if (em) _commitRecipient(input, sugg, em);
}
}
});
}
function _hideEmailFields() {
const emailHeader = document.getElementById('doc-email-header');
const emailActions = document.getElementById('doc-email-actions');
if (emailHeader) emailHeader.style.display = 'none';
if (emailActions) emailActions.style.display = 'none';
// Restore toolbar items that were hidden for email (Code dropdown).
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; });
// Restore the generic documents action bar + its bottom footer (Close /
// Copy / Export) for non-email docs.
const docActions = document.getElementById('doc-editor-actions');
if (docActions) docActions.style.display = '';
const docFooter = document.getElementById('doc-actions-footer');
if (docFooter) docFooter.style.display = '';
// Return the type picker to its non-email home (right before the
// Copy/Export split) — _showEmailFields moved it into the email footer.
if (docFooter) {
const _lang = document.getElementById('doc-language-select');
const _split = docFooter.querySelector('#doc-copy-export-split');
if (_lang && _split) docFooter.insertBefore(_lang, _split);
}
// Restore the source editor and hide the WYSIWYG email body.
const _rich = document.getElementById('doc-email-richbody');
if (_rich) _rich.style.display = 'none';
const _srcWrap = document.getElementById('doc-editor-wrap');
if (_srcWrap) _srcWrap.style.display = '';
// Drop the email-mode class so editors return to monospace monochrome
document.getElementById('doc-editor-textarea')?.classList.remove('email-mode');
document.getElementById('doc-editor-code')?.classList.remove('email-mode');
document.getElementById('doc-editor-highlight')?.classList.remove('email-mode');
}
const _ATTACH_RE = /\b(attach(ed|ment|ments|ing)?|enclosed|enclosing|PFA|find attached|see attached|ci-joint|en pi[eè]ce jointe|ajout[eé]|joint|jointe|anbei|im Anhang|beigef[uü]gt|添付|fichier joint)\b/i;
function _bodyMentionsAttachment(text) {
if (!text) return false;
// Only check the user's own text, not quoted replies
const parts = text.split(/^>|^On .* wrote:/m);
const own = parts[0] || '';
return _ATTACH_RE.test(own);
}
function _confirmMissingAttachment() {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'modal';
overlay.style.display = 'flex';
overlay.innerHTML = `
No attachments found
Your message mentions an attachment, but nothing is attached. Send anyway?
`;
document.body.appendChild(overlay);
const cleanup = (val) => { overlay.remove(); resolve(val); };
overlay.querySelector('#att-warn-cancel').addEventListener('click', () => cleanup(false));
overlay.querySelector('#att-warn-send').addEventListener('click', () => cleanup(true));
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); });
});
}
async function _sendEmail() {
const sendDocId = activeDocId;
const to = document.getElementById('doc-email-to')?.value?.trim();
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || '';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim();
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
// WYSIWYG: the rich body's HTML becomes the email's HTML part (server
// sanitizes it). `body` (plain text mirror) stays the text/plain fallback.
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
if (!to || !body) {
if (uiModule) uiModule.showError('To and body are required');
return;
}
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
// Warn if body mentions attachments but none are actually attached
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment();
if (!proceed) return;
}
const btn = document.getElementById('doc-email-send-btn');
const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
let sendSpinner = null;
let origBtnHtml = '';
let detachedEmailDoc = null;
if (btn) {
btn.disabled = true;
origBtnHtml = btn.innerHTML;
sendSpinner = spinnerModule.createWhirlpool(14);
sendSpinner.element.style.cssText = 'display:inline-block;vertical-align:-2px;margin-right:6px;width:14px;height:14px;';
btn.innerHTML = '';
btn.appendChild(sendSpinner.element);
btn.appendChild(document.createTextNode('Sending'));
}
try {
let canceled = false;
if (uiModule) {
uiModule.showToast('Sending', {
duration: 3200,
leadingIcon: 'spinner',
action: 'Cancel',
onAction: () => { canceled = true; },
});
}
await _sleep(3000);
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
await _sleep(200);
if (canceled) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showToast('Send canceled');
return;
}
const activeAccountId = await _resolveComposeSendAccountId();
const res = await fetch(`${API_BASE}/api/email/send`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml,
in_reply_to: inReplyTo || null, references: references || null,
attachments: attachments.length > 0 ? attachments : null,
account_id: activeAccountId,
wait_for_delivery: true,
}),
});
let data = null;
try {
data = await res.json();
} catch (_) {
data = { success: false, error: `Send failed (${res.status})` };
}
if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`;
if (data.success) {
if (uiModule) {
uiModule.showToast('Message sent', {
duration: 7000,
leadingIcon: 'check',
action: 'View Message',
onAction: () => {
import('./emailLibrary.js').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({
account_id: data.account_id || activeAccountId || null,
folder: data.sent_folder || 'Sent',
uid: data.sent_uid || null,
});
}).catch(() => {});
},
});
}
// Auto-save recipients to the configured contacts backend (CardDAV).
// The compose fields accept plain emails and "Name " chips.
const _contactPieces = [to, cc, bcc].join(',').split(/[,;]/).map(s => s.trim()).filter(Boolean);
const _seenContacts = new Set();
for (const piece of _contactPieces) {
const match = piece.match(/^(.*?)<([^>]+)>$/);
const email = (match ? match[2] : piece).trim();
const name = (match ? match[1] : '').replace(/^["']|["']$/g, '').trim();
if (!email || !/@/.test(email)) continue;
const key = email.toLowerCase();
if (_seenContacts.has(key)) continue;
_seenContacts.add(key);
fetch(`${API_BASE}/api/contacts/add`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
}).catch(() => {});
}
// Mark the source email as answered if this was a reply
if (sourceUid) {
fetch(`${API_BASE}/api/email/mark-answered/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }).catch(() => {});
// Tell the inbox to refresh so the answered state shows
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid } }));
}
// Delete the compose document after successful send. It was usually
// already detached from the visible tabs so sending can finish in the
// background while the user continues in the next tab.
if (sendDocId) {
fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' }).catch(() => {});
const wasActiveSentDoc = activeDocId === sendDocId;
docs.delete(sendDocId);
if (wasActiveSentDoc) {
activeDocId = null;
const nextId = _visibleDocIdsForCurrentSession().find(id => docs.has(id));
if (nextId) switchToDoc(nextId);
else closePanel();
} else {
renderTabs();
}
_syncDocIndicator();
}
} else {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showError(data.error || 'Failed to send');
}
} catch (e) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showError(e?.message ? `Failed to send email: ${e.message}` : 'Failed to send email');
} finally {
if (sendSpinner) sendSpinner.destroy();
if (btn) {
btn.disabled = false;
if (origBtnHtml) btn.innerHTML = origBtnHtml;
}
}
}
async function _saveDraft() {
const to = document.getElementById('doc-email-to')?.value?.trim();
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || '';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const btn = document.getElementById('doc-email-draft-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 18000);
try {
const res = await fetch(`${API_BASE}/api/email/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
to: to || '',
cc: cc || null,
bcc: bcc || null,
subject: subject || '',
body: body || '',
body_html: bodyHtml,
in_reply_to: inReplyTo || null,
references: references || null,
account_id: window.__odysseusActiveEmailAccount || null,
}),
});
const data = await res.json();
if (data.success) {
if (uiModule) uiModule.showToast('Draft saved to mailbox');
} else {
if (uiModule) uiModule.showError(data.error || 'Failed to save draft');
}
} catch (e) {
const timedOut = e && e.name === 'AbortError';
if (uiModule) uiModule.showError(timedOut ? 'Saving draft timed out' : 'Failed to save draft');
} finally {
clearTimeout(timeout);
if (btn) { btn.disabled = false; btn.textContent = 'Draft'; }
}
}
function _discardEmail() {
if (!activeDocId) return;
// Just close — the Draft button handles saving explicitly
_closeWithoutDeleting(true);
}
function _visibleDocIdsForCurrentSession() {
const curSession = sessionModule?.getCurrentSessionId() || '';
const ids = [];
for (const [id, doc] of docs) {
if (doc.sessionId && curSession && doc.sessionId !== curSession) continue;
ids.push(id);
}
return ids;
}
function _detachActiveEmailForBackground(docId) {
if (!docId || !docs.has(docId)) return null;
saveCurrentToMap();
const doc = docs.get(docId);
const snapshot = { id: docId, doc: { ...doc } };
saveDocument({ silent: true }).catch(() => {});
const visibleBefore = _visibleDocIdsForCurrentSession();
const idx = visibleBefore.indexOf(docId);
docs.delete(docId);
if (activeDocId === docId) activeDocId = null;
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
if (nextId) {
switchToDoc(nextId);
} else {
closePanel();
}
renderTabs();
_syncDocIndicator();
return snapshot;
}
function _restoreDetachedEmailDoc(snapshot) {
if (!snapshot || !snapshot.id || !snapshot.doc) return;
if (!docs.has(snapshot.id)) docs.set(snapshot.id, snapshot.doc);
_ensureDocPaneMounted();
switchToDoc(snapshot.id);
_syncDocIndicator();
}
function _closeWithoutDeleting(deleteDoc = false) {
if (!activeDocId) return;
if (deleteDoc) {
fetch(`${API_BASE}/api/document/${activeDocId}`, { method: 'DELETE' }).catch(() => {});
}
// Save the current state to the doc first so it persists in the library
saveCurrentToMap();
if (!deleteDoc) {
saveDocument({ silent: true }).catch(() => {});
}
docs.delete(activeDocId);
const remaining = Array.from(docs.keys());
if (remaining.length > 0) {
switchToDoc(remaining[0]);
} else {
closePanel();
}
renderTabs();
}
async function _aiReply() {
const to = document.getElementById('doc-email-to')?.value?.trim() || '';
const subject = document.getElementById('doc-email-subject')?.value?.trim() || '';
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
const currentBody = textarea.value || '';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const cleanAiReplyText = (text) => {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
};
const shouldUseFastAiReply = () => {
const text = `${subject}\n${currentBody}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return currentBody.length < 2500;
};
// Use the current chat model
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const btn = document.getElementById('doc-email-ai-reply-btn');
if (btn) { btn.disabled = true; btn.innerHTML = 'Drafting...'; }
try {
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: to,
subject: subject,
original_body: currentBody,
model: currentModel,
session_id: currentSessionId,
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: shouldUseFastAiReply(),
}),
});
const data = await res.json();
if (data.success && data.reply) {
const cleanReply = cleanAiReplyText(data.reply);
const lines = currentBody.split('\n');
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
let newBody = '';
if (quoteIdx > 0) {
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
} else {
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
}
await _streamEmailBodyText(textarea, newBody);
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
} else {
if (uiModule) uiModule.showError(data.error || 'Failed to generate reply');
}
} catch (e) {
if (uiModule) uiModule.showError('Failed to generate AI reply');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = 'AI Reply'; }
}
}
async function _scheduleSend(anchorEl = null) {
const to = document.getElementById('doc-email-to')?.value?.trim();
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || '';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const body = (_rich
? (_rich.innerText || _rich.textContent || '')
: (document.getElementById('doc-editor-textarea')?.value || '')
).trim();
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
if (!to || !body) {
if (uiModule) uiModule.showError('To and body are required');
return;
}
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment();
if (!proceed) return;
}
// Create a small modal with datetime input and quick presets
const overlay = document.createElement('div');
overlay.className = 'modal';
overlay.style.display = 'flex';
overlay.innerHTML = `
Schedule Send
`;
document.body.appendChild(overlay);
const modalContent = overlay.querySelector('.schedule-send-modal');
const anchor = anchorEl || document.getElementById('doc-email-send-caret') || document.getElementById('doc-email-send-btn');
if (modalContent && anchor) {
const rect = anchor.getBoundingClientRect();
const gap = 8;
const width = Math.min(400, Math.max(280, window.innerWidth - 16));
modalContent.style.width = `${width}px`;
modalContent.style.position = 'fixed';
modalContent.style.margin = '0';
modalContent.style.transform = 'none';
const left = Math.max(8, Math.min(window.innerWidth - width - 8, rect.right - width));
const belowTop = rect.bottom + gap;
const estimatedHeight = Math.min(320, window.innerHeight - 16);
const top = belowTop + estimatedHeight <= window.innerHeight - 8
? belowTop
: Math.max(8, rect.top - estimatedHeight - gap);
modalContent.style.left = `${left}px`;
modalContent.style.top = `${top}px`;
}
const dtInput = overlay.querySelector('#sched-datetime');
// Default to 1 hour from now
const now = new Date(Date.now() + 60 * 60 * 1000);
const pad = (n) => String(n).padStart(2, '0');
dtInput.value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
const escHandler = (e) => { if (e.key === 'Escape') cleanup(); };
const cleanup = () => {
overlay.remove();
document.removeEventListener('keydown', escHandler);
};
overlay.querySelector('#sched-close').addEventListener('click', cleanup);
overlay.querySelector('#sched-cancel').addEventListener('click', cleanup);
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
document.addEventListener('keydown', escHandler);
overlay.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => {
const preset = btn.getAttribute('data-preset');
const d = new Date();
if (preset === '1h') d.setHours(d.getHours() + 1);
else if (preset === '3h') d.setHours(d.getHours() + 3);
else if (preset === 'tomorrow') { d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0); }
else if (preset === 'monday') {
const daysUntilMon = (8 - d.getDay()) % 7 || 7;
d.setDate(d.getDate() + daysUntilMon);
d.setHours(9, 0, 0, 0);
}
dtInput.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
});
});
overlay.querySelector('#sched-confirm').addEventListener('click', async () => {
const localDt = dtInput.value;
if (!localDt) { if (uiModule) uiModule.showError('Please pick a time'); return; }
// Convert local datetime to UTC ISO
const utcIso = new Date(localDt).toISOString();
try {
const activeAccountId = await _resolveComposeSendAccountId();
const res = await fetch(`${API_BASE}/api/email/schedule`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to, cc: cc || null, bcc: bcc || null, subject, body,
in_reply_to: inReplyTo || null,
references: references || null,
attachments: attachments.length > 0 ? attachments : null,
send_at: utcIso,
account_id: activeAccountId,
}),
});
const data = await res.json();
if (data.success) {
if (uiModule) uiModule.showToast(`Scheduled for ${new Date(localDt).toLocaleString()}`);
cleanup();
// Close the document
_closeWithoutDeleting(true);
} else {
if (uiModule) uiModule.showError(data.error || 'Failed to schedule');
}
} catch (e) {
if (uiModule) uiModule.showError('Failed to schedule');
}
});
}
async function _markUnreadAndClose() {
const sourceUid = document.getElementById('doc-email-source-uid')?.value || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value || 'INBOX';
if (sourceUid) {
try {
await fetch(`${API_BASE}/api/email/mark-unread/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' });
} catch (e) { console.error('Failed to mark unread:', e); }
}
_discardEmail();
}
function switchToDoc(docId) {
if (!docs.has(docId)) return;
_hideLoadingOverlay();
if (_diffModeActive) exitDiffMode(true);
// Save current doc state before switching
saveCurrentToMap();
// Auto-delete the doc we're leaving if it's completely empty
const prevId = activeDocId;
if (prevId && prevId !== docId && docs.has(prevId)) {
const prev = docs.get(prevId);
if (!(prev.content || '').trim() && !(prev.title || '').trim()) {
fetch(`${API_BASE}/api/document/${prevId}`, { method: 'DELETE' }).catch(() => {});
docs.delete(prevId);
_syncDocIndicator();
}
}
activeDocId = docId;
clearSelection();
const doc = docs.get(docId);
// Populate editor
const titleInput = document.getElementById('doc-title-input');
const textarea = document.getElementById('doc-editor-textarea');
const langSelect = document.getElementById('doc-language-select');
const badge = document.getElementById('doc-version-badge');
if (titleInput) titleInput.value = doc.title || '';
// For email docs, _showEmailFields will set textarea to body only (not raw header)
if (textarea && doc.language !== 'email') textarea.value = doc.content || '';
if (langSelect) langSelect.value = doc.language || 'markdown';
if (badge) { const _v = doc.version || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; }
{ const _v = doc.version || 1; const _dbtn = document.getElementById('doc-diff-toggle-btn'); if (_dbtn) _dbtn.style.display = _v > 1 ? '' : 'none'; }
syncHighlighting();
// Deferred re-sync: ensure minHeight is correct after browser layout
requestAnimationFrame(() => {
const ta2 = document.getElementById('doc-editor-textarea');
const code2 = document.getElementById('doc-editor-code');
const pre2 = document.getElementById('doc-editor-highlight');
if (ta2 && code2 && pre2) {
code2.style.minHeight = ta2.scrollHeight + 'px';
pre2.scrollTop = ta2.scrollTop;
}
});
// Auto-detect language for docs with no language set
if (!doc.userSetLanguage && !doc.language) {
setTimeout(attemptAutoDetect, 100);
}
// Show/hide markdown toolbar based on language. PDF-backed docs are
// markdown under the hood, so the toolbar shows up for them too — and
// gets the PDF-specific buttons (Text/Check/Sign/AI) revealed below.
const isMd = (doc.language || 'markdown') === 'markdown';
const isPdf = _isFormBackedDoc(doc.content || '');
// For PDF-backed docs, re-run text extraction on the backend so the AI
// can see the contents on the very next message. Idempotent + skipped
// once per session per doc to avoid hammering the VL model on every
// switch — track via a sentinel on the doc object.
if (isPdf && !doc._ocrTriggered) {
doc._ocrTriggered = true;
(async () => {
try {
const r = await fetch(`${API_BASE}/api/document/${docId}/extract-pdf-text`, { method: 'POST', credentials: 'same-origin' });
if (!r.ok) return;
const j = await r.json().catch(() => ({}));
if (j && j.extracted) {
// Pull the fresh content into the local cache so subsequent AI
// turns and the source view both reflect the extraction.
const dr = await fetch(`${API_BASE}/api/document/${docId}`, { credentials: 'same-origin' });
if (dr.ok) {
const full = await dr.json();
const cached = docs.get(docId);
if (cached && full && full.current_content) {
cached.content = full.current_content;
}
}
}
} catch (_) {}
})();
}
const mdToolbar = document.getElementById('doc-md-toolbar');
if (mdToolbar) {
// Show for every doc type so users always have access to font-size /
// diff toggle / language-specific controls. Items inside the toolbar
// gate their own visibility on language (md edit/preview toggle, etc).
mdToolbar.style.display = '';
if (mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow);
}
// Toggle PDF-only toolbar group
document.querySelectorAll('.md-toolbar-pdf-only').forEach(el => {
el.style.display = isPdf ? '' : 'none';
});
// Font size does nothing for a PDF (annotations are placed, not styled) —
// hide it on PDFs so the toolbar only shows what actually works.
const _fsBtn = document.getElementById('doc-fontsize-btn');
if (_fsBtn) _fsBtn.style.display = isPdf ? 'none' : '';
// Exit CSV preview when switching docs, or auto-show for CSV
const isCsv = doc.language === 'csv';
const csvPreview = document.getElementById('doc-csv-preview');
if (!isCsv) {
if (csvPreview) csvPreview.style.display = 'none';
} else {
// Auto-show table view for CSV documents
requestAnimationFrame(() => toggleCsvPreview());
}
// Exit HTML preview on switch
exitHtmlPreview();
// Show/hide email fields. Markdown preview uses the same editor wrapper
// as email source mode, so clear it before showing the rich email body;
// otherwise the source wrapper can reappear over the composer.
const isEmail = doc.language === 'email';
if (isEmail) {
_setMarkdownPreviewActive(false, { remember: false });
_showEmailFields(doc);
} else {
_hideEmailFields();
const wantsMarkdownPreview = (doc.language || 'markdown') === 'markdown' && doc._markdownPreviewActive === true;
_setMarkdownPreviewActive(wantsMarkdownPreview, { remember: false });
}
// Hide version panel on switch
const vp = document.getElementById('doc-version-panel');
if (vp) vp.classList.add('hidden');
renderTabs();
_syncHeaderActions();
// Restore any persisted suggestions for this doc
if (_activeSuggestions.length === 0) {
_restoreSuggestionsFromStorage(docId);
}
}
// Detach a doc from its chat session so it stops reappearing in that
// chat: docs with content are unlinked (kept in the library), empty docs
// are deleted. Used by both the tab × and the mobile chip-to-trash close.
function _detachDocFromSession(docId, { toast = false } = {}) {
const doc = docs.get(docId);
const hasContent = doc && doc.content && doc.content.trim().length > 0;
if (hasContent) {
fetch(`${API_BASE}/api/document/${docId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: '' }),
}).then(() => {
if (toast && uiModule) uiModule.showToast('Document unlinked from session');
}).catch(() => {});
} else {
fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE' }).catch(() => {});
}
docs.delete(docId);
_syncDocIndicator();
}
async function closeTab(docId) {
// Save current editor content to map so the check below uses fresh data
saveCurrentToMap();
_detachDocFromSession(docId, { toast: true });
// Find next tab in the current session
const curSession = sessionModule?.getCurrentSessionId() || '';
let nextId = null;
for (const [id, d] of docs) {
if (!d.sessionId || !curSession || d.sessionId === curSession) {
nextId = id;
break;
}
}
if (!nextId) {
activeDocId = null;
closePanel();
return;
}
if (activeDocId === docId) {
switchToDoc(nextId);
} else {
renderTabs();
}
}
/** Auto-create a document when user types/pastes into empty editor */
let _autoCreating = false;
// True while createDocument's POST is in flight — suppresses the type-to-
// auto-create path so clicking "New document" and immediately typing can't
// spawn a SECOND untitled doc (the create round-trip hadn't set activeDocId
// yet, so the input handler thought the editor was empty).
let _creatingDoc = false;
async function _autoCreateFromInput(content) {
if (_autoCreating) return;
_autoCreating = true;
try {
let sessionId = _lastSessionId
|| (sessionModule && sessionModule.getCurrentSessionId());
if (!sessionId) {
sessionId = await _autoCreateSession();
}
const res = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, title: '', content }),
});
const doc = await res.json();
addDocToTabs(doc, sessionId);
// Set the content into the map so switchToDoc preserves it
const d = docs.get(doc.id);
if (d) d.content = content;
activeDocId = doc.id;
// Update textarea (keep existing content the user typed)
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) {
textarea.placeholder = 'Document content...';
}
syncHighlighting();
renderTabs();
// Trigger auto-detect and auto-title
setTimeout(attemptAutoDetect, 100);
setTimeout(() => autoTitleFromContent(content), 300);
// Auto-save
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000);
} catch (e) {
console.error('Failed to auto-create document from input:', e);
} finally {
_autoCreating = false;
}
}
/** Save current editor state back into the docs map */
function saveCurrentToMap() {
if (!activeDocId || !docs.has(activeDocId)) return;
const doc = docs.get(activeDocId);
const textarea = document.getElementById('doc-editor-textarea');
const titleInput = document.getElementById('doc-title-input');
const langSelect = document.getElementById('doc-language-select');
if (titleInput) doc.title = titleInput.value;
if (langSelect) doc.language = langSelect.value;
// For email docs, reconstruct full content with header
if (doc.language === 'email' && textarea) {
const to = document.getElementById('doc-email-to')?.value || '';
const cc = document.getElementById('doc-email-cc')?.value || '';
const bcc = document.getElementById('doc-email-bcc')?.value || '';
const subject = document.getElementById('doc-email-subject')?.value || '';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value || '';
const references = document.getElementById('doc-email-references')?.value || '';
const sourceUid = document.getElementById('doc-email-source-uid')?.value || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value || '';
// Persist the WYSIWYG body as HTML so reopening the draft keeps its
// formatting (the textarea mirror is plain text). _emailBodyToHtml detects
// the leading '<' on reload and restores it verbatim.
const _rich = document.getElementById('doc-email-richbody');
const _emailBody = (_rich && _rich.style.display !== 'none') ? _rich.innerHTML : textarea.value;
doc.content = _buildEmailContent(to, subject, inReplyTo, references, _emailBody, sourceUid, sourceFolder, cc, bcc);
} else if (textarea) {
// Don't clobber a PDF/form-backed doc's source when the textarea is empty
// (it's hidden behind the rendered PDF view, so its value isn't the source
// of truth). Overwriting here dropped the pdf_form_source marker, so after
// minimize→restore the doc came back blank.
if (!(textarea.value === '' && _isFormBackedDoc(doc.content))) {
doc.content = textarea.value;
}
}
}
// ---- Panel open/close ----
export function openPanel() {
if (isOpen) return;
// Clear any pane/divider still sliding out from a just-fired close so we
// don't end up with two #doc-editor-pane nodes (and a stale close stripping
// doc-view). Paired with the isOpen guard in _finishClose above.
document.getElementById('doc-editor-pane')?.remove();
document.getElementById('doc-divider')?.remove();
// If the doc was minimized as a chip and the user opened the panel via
// a different path (toolbar button, indicator), clear that chip — the
// doc is becoming visible again.
if (Modals.isRegistered('doc-panel') && Modals.isMinimized('doc-panel')) {
_minimizedDocId = null;
Modals.unregister('doc-panel');
}
isOpen = true;
// Doc was opened last → it goes in front of the email windows (clears the
// email-front flag; the doc/email z-index alternation lives in CSS).
document.body.classList.remove('email-front');
_ensureAgentMode();
_markDocVisibleState(_lastSessionId, 'open');
const container = document.getElementById('chat-container');
if (!container) return;
document.body.classList.add('doc-view');
// Sync toggle button state
const toggleBtn = document.getElementById('overflow-doc-btn');
if (toggleBtn) toggleBtn.classList.add('active');
const docInd = document.getElementById('doc-indicator-btn');
if (docInd) docInd.classList.add('active');
// Create divider — grip in the middle (drag-to-resize), swapped for a
// clickable collapse chevron on hover.
const divider = document.createElement('div');
divider.className = 'doc-divider';
divider.id = 'doc-divider';
// Single chevron that swaps direction based on cursor position:
// - cursor INSIDE the doc pane → › (collapse / close panel)
// - cursor OUTSIDE the doc pane → ‹ (fullscreen — grow leftward)
// The arrow rotates via CSS so the swap feels clean. The action follows
// the glyph, so clicking always does what the arrow promises.
// The secondary X button below it is only shown in fullscreen mode and
// hides the pane outright (so fullscreen has an escape that isn't just
// "exit fullscreen").
divider.innerHTML = '' +
'';
const _divHide = divider.querySelector('.doc-divider-hide');
if (_divHide) {
_divHide.addEventListener('mousedown', (e) => e.stopPropagation());
_divHide.addEventListener('click', (e) => { e.stopPropagation(); closePanel('down'); });
}
// Create the editor pane
const pane = document.createElement('div');
pane.id = 'doc-editor-pane';
pane.className = 'doc-editor-pane';
// ── Mobile: make toolbar/footer buttons work on the FIRST tap with the
// keyboard up ──
// Normally a tap while the keyboard is open is eaten by the OS keyboard
// dismissal and the button's click never fires ("nothing triggers"). Keep
// the field focused through the press so the tap isn't consumed, then
// re-dispatch the click on release so the action fires on the first tap.
// The action handler itself decides whether to then drop the keyboard
// (Undo/Export/Close do; Format/Copy keep it). Touch only — desktop is
// untouched.
{
let _kbBtn = null;
pane.addEventListener('pointerdown', (e) => {
_kbBtn = null;
if (e.pointerType !== 'touch') return;
const btn = e.target.closest && e.target.closest('button');
if (!btn) return;
const ae = document.activeElement;
if (!(ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA'))) return;
e.preventDefault(); // keep focus; this cancels the native touch click
_kbBtn = btn;
}, true);
pane.addEventListener('pointerup', (e) => {
const btn = _kbBtn; _kbBtn = null;
if (!btn) return;
if (e.target.closest && e.target.closest('button') === btn) {
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
}, true);
pane.addEventListener('pointercancel', () => { _kbBtn = null; }, true);
}
pane.innerHTML = `
editingv1
1
Save Draft
Schedule Send...
Mark Unread
Version History
`;
// Consolidate into a SINGLE action bar: move Undo + the type picker out of
// the top header into the bottom footer (left side, next to Close) so a
// regular doc shows one bar, not two. The rest of the header (run/preview,
// fullscreen, version, PDF) stays put; the header hides itself when nothing
// in it is visible — see _syncHeaderBarVisibility().
// Note: `#doc-render-view-toggle` (code↔run for SVG/HTML) intentionally
// stays in the top header so it matches `#doc-md-view-toggle` (markdown
// edit↔preview) — both view toggles live in the same place.
{
const _footer = pane.querySelector('#doc-actions-footer');
const _split = _footer && _footer.querySelector('#doc-copy-export-split');
const _undo = pane.querySelector('#doc-undo-btn');
const _lang = pane.querySelector('#doc-language-select');
const _preview = pane.querySelector('#doc-header-preview-btn'); // single Run ▶ for python/bash/js/csv
const _exportPdf = pane.querySelector('#doc-export-pdf-btn');
const _pdfView = pane.querySelector('#doc-pdf-view-btn');
if (_footer && _split) {
// Footer order (left → right): Undo, Run/Preview, Lang, …, Copy/Export.
// The X close was here too but is now redundant with the per-tab close
// button in the title strip — removed.
if (_undo) _footer.insertBefore(_undo, _footer.firstChild);
const _anchor = _undo;
if (_preview && _anchor) _anchor.after(_preview);
if (_lang) _split.before(_lang);
// Pull every remaining header-only control into the footer so we
// only ever render ONE bottom action row. The standalone top header
// was leaving a duplicate row above (with fullscreen + version badge
// + stream indicator). Each item keeps its own display: toggling.
const _streamInd = pane.querySelector('#doc-stream-indicator');
const _versionBadge = pane.querySelector('#doc-version-badge');
if (_split) {
if (_pdfView) _split.before(_pdfView);
if (_exportPdf) _split.before(_exportPdf);
if (_versionBadge) _split.before(_versionBadge);
if (_streamInd) _split.before(_streamInd);
}
}
// iOS keeps the soft keyboard up when you tap a