2026-05-31 23:58:26 +09:00
// 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 ;
2026-06-02 22:38:55 +09:00
let _emailHeaderManualExpandUntil = 0 ;
2026-05-31 23:58:26 +09:00
// 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 ,
} ) ;
2026-06-02 04:48:54 +02:00
_maybeOpenDocFromHash ( ) ;
window . addEventListener ( 'hashchange' , _maybeOpenDocFromHash ) ;
2026-05-31 23:58:26 +09:00
}
/** 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 += '<button class="doc-tab-arrow doc-tab-arrow-left" id="doc-tab-left" title="Scroll left">‹</button>' ;
html += '<div class="doc-tab-scroll" id="doc-tab-scroll">' ;
const curSession = sessionModule ? . getCurrentSessionId ( ) || '' ;
let _anyTab = false ;
for ( const [ id , doc ] of docs ) {
// 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 = ` <button class="doc-tab-menu-btn" data-doc-id=" ${ id } " title="Document actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="19" r="2.5"/></svg></button> ` ;
const ver = doc . version || doc . version _count || 1 ;
const verChip = ` <span class="doc-tab-version" data-doc-id=" ${ id } " title="Version history">v ${ ver } </span> ` ;
// 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 = ` <span class="doc-tab-lang"> ${ lic } </span> ` ;
html += ` <div class="doc-tab ${ isActive ? ' active' : '' } " draggable="true" data-doc-id=" ${ id } " title=" ${ title } ">
$ { verChip } $ { langChip } < span class = "doc-tab-title" > $ { shortTitle } < / s p a n >
< button class = "doc-tab-close" data - doc - id = "${id}" title = "Unlink from chat (kept in the Library)" > & times ; < / b u t t o n >
< / d i v > ` ;
}
// 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 += ` <div class="doc-tab active doc-tab-ghost" title="New document — start typing"><span class="doc-tab-title">Untitled</span></div> ` ;
}
html += ` <button class="doc-tab-new" id="doc-tab-new-btn" title="New document"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button> ` ;
html += '</div>' ;
html += '<button class="doc-tab-arrow doc-tab-arrow-right" id="doc-tab-right" title="Scroll right">›</button>' ;
tabBar . innerHTML = html ;
// 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 /<!--\s*pdf_form_source\s+upload_id="[^"]+"/ . test ( c )
|| /<!--\s*pdf_source\s+upload_id="[^"]+"/ . test ( c ) ;
}
// Force the on-screen keyboard down on touch. Firefox mobile ignores a plain
// blur, so use the readonly trick (a readonly field shows no keyboard), then
// drop readonly so the user can type again.
function _dismissDocKb ( ) {
if ( ! ( ( 'ontouchstart' in window ) || ( navigator . maxTouchPoints || 0 ) > 0 ) ) return ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
const ae = document . activeElement ;
const el = ( ae && ( ae . tagName === 'INPUT' || ae . tagName === 'TEXTAREA' ) ) ? ae : ta ;
if ( ! el ) return ;
try {
el . setAttribute ( 'readonly' , 'readonly' ) ;
el . blur ( ) ;
setTimeout ( ( ) => { try { el . removeAttribute ( 'readonly' ) ; } catch ( _ ) { } } , 120 ) ;
} catch ( _ ) { try { el . blur ( ) ; } catch ( _ ) { } }
}
async function _downloadFilledPdf ( ) {
if ( ! activeDocId ) return ;
_dismissDocKb ( ) ; // export shouldn't leave the keyboard up
await _saveActiveDocBeforeExport ( ) ;
try {
const r = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /export-pdf ` ) ;
if ( ! r . ok ) {
const t = await r . text ( ) ;
throw new Error ( t || r . statusText ) ;
}
const blob = await r . blob ( ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
const cd = r . headers . get ( 'Content-Disposition' ) || '' ;
const m = cd . match ( /filename\*?=(?:UTF-8'')?"?([^"';]+)/i ) ;
const _slug = ( s ) => ( s || 'form' ) . replace ( /\.pdf$/i , '' ) . replace ( /\s+/g , '_' ) . replace ( /[^A-Za-z0-9._-]/g , '' ) . replace ( /_+/g , '_' ) . replace ( /^_|_$/g , '' ) || 'form' ;
a . download = ( m && decodeURIComponent ( m [ 1 ] ) ) || ( _slug ( docs . get ( activeDocId ) ? . title ) + '_annotated.pdf' ) ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 1000 ) ;
} catch ( e ) {
if ( uiModule ) uiModule . showError ( 'Export failed: ' + e . message ) ;
else alert ( 'Export failed: ' + e . message ) ;
}
}
async function _saveActiveDocBeforeExport ( ) {
// Flush in-flight edits from BOTH editing surfaces so the server-side
// export reads the values the user actually sees:
// - Markdown view: textarea.value may differ from doc.content if the
// user typed but the existing 2s autosave hasn't fired.
// - PDF view: there may be a pending debounced _pdfPaneSaveTimer that
// hasn't flushed the user's input changes yet.
if ( _pdfPaneSaveTimer ) {
clearTimeout ( _pdfPaneSaveTimer ) ;
await _savePdfPaneToMarkdown ( ) ;
}
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
const doc = docs . get ( activeDocId ) ;
if ( ! ta || ! doc || ! activeDocId ) return ;
const live = ta . value ;
if ( live === doc . content ) return ;
try {
await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { content : live } ) ,
} ) ;
doc . content = live ;
} catch ( e ) {
console . warn ( 'Pre-export save failed:' , e ) ;
}
}
async function _openExportPdfModal ( ) {
if ( ! activeDocId ) return ;
await _saveActiveDocBeforeExport ( ) ;
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'modal pdf-export-overlay' ;
overlay . style . cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);' ;
overlay . innerHTML = `
< div class = "modal-content" style = "width:min(780px,94vw);max-height:86vh;" >
< div class = "modal-header" >
< h4 > Export filled PDF < / h 4 >
< button id = "pdf-export-close" class = "modal-close" title = "Close" > × < / b u t t o n >
< / d i v >
< div id = "pdf-export-summary" style = "font-size:0.78rem;opacity:0.7;margin:0 0 6px;" > Loading field values … < / d i v >
< div id = "pdf-export-body" class = "modal-body" style = "font-size:0.85rem;" >
< div style = "opacity:0.6;" > Fetching mapping … < / d i v >
< / d i v >
< div class = "modal-footer" style = "display:flex;justify-content:flex-end;gap:8px;padding-top:8px;border-top:1px solid var(--border);margin-top:6px;align-items:center;" >
< span id = "pdf-export-status" style = "font-size:0.75rem;opacity:0.7;margin-right:auto;" > < / s p a n >
< button id = "pdf-export-cancel" class = "confirm-btn confirm-btn-secondary" > Cancel < / b u t t o n >
< button id = "pdf-export-download" class = "confirm-btn confirm-btn-primary" disabled > Download PDF < / b u t t o n >
< / d i v >
< / d i v >
` ;
document . body . appendChild ( overlay ) ;
const close = ( ) => overlay . remove ( ) ;
overlay . addEventListener ( 'click' , ( e ) => { if ( e . target === overlay ) close ( ) ; } ) ;
overlay . querySelector ( '#pdf-export-close' ) . addEventListener ( 'click' , close ) ;
overlay . querySelector ( '#pdf-export-cancel' ) . addEventListener ( 'click' , close ) ;
let fields = [ ] ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /export-pdf/preview ` , { method : 'POST' } ) ;
if ( ! res . ok ) {
const err = await res . text ( ) ;
throw new Error ( err || res . statusText ) ;
}
const data = await res . json ( ) ;
fields = data . fields || [ ] ;
const filledNow = data . filled || 0 ;
const total = data . total || fields . length ;
overlay . querySelector ( '#pdf-export-summary' ) . textContent =
` ${ filledNow } of ${ total } fields filled. Review and adjust below before downloading. ` ;
const body = overlay . querySelector ( '#pdf-export-body' ) ;
body . innerHTML = '' ;
// Group by page
const byPage = new Map ( ) ;
for ( const f of fields ) {
const p = f . page || 1 ;
if ( ! byPage . has ( p ) ) byPage . set ( p , [ ] ) ;
byPage . get ( p ) . push ( f ) ;
}
const pages = Array . from ( byPage . keys ( ) ) . sort ( ( a , b ) => a - b ) ;
// Jump bar: page links + scroll-to-top/bottom shortcuts
const jumpBar = document . createElement ( 'div' ) ;
jumpBar . style . cssText = 'position:sticky;top:0;background:var(--panel);padding:6px 0;margin-bottom:8px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:0.72rem;z-index:1;' ;
jumpBar . innerHTML = '<span style="opacity:0.6;margin-right:4px;">Jump to:</span>' ;
const pageAnchors = { } ;
const _smallBtnClass = 'confirm-btn confirm-btn-secondary' ;
const _smallBtnStyle = 'padding:2px 8px;font-size:0.72rem;' ;
for ( const p of pages ) {
const a = document . createElement ( 'button' ) ;
a . textContent = String ( p ) ;
a . title = ` Jump to page ${ p } ` ;
a . className = _smallBtnClass ;
a . style . cssText = _smallBtnStyle ;
a . addEventListener ( 'click' , ( ) => pageAnchors [ p ] ? . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ) ;
jumpBar . appendChild ( a ) ;
}
const sep = document . createElement ( 'span' ) ;
sep . style . cssText = 'opacity:0.4;margin:0 4px;' ;
sep . textContent = '|' ;
jumpBar . appendChild ( sep ) ;
const topBtn = document . createElement ( 'button' ) ;
topBtn . textContent = '↑ Top' ;
topBtn . className = _smallBtnClass ;
topBtn . style . cssText = _smallBtnStyle ;
topBtn . addEventListener ( 'click' , ( ) => body . scrollTo ( { top : 0 , behavior : 'smooth' } ) ) ;
jumpBar . appendChild ( topBtn ) ;
const botBtn = document . createElement ( 'button' ) ;
botBtn . textContent = '↓ Bottom' ;
botBtn . title = 'Jump to the last page (signature fields are usually here)' ;
botBtn . className = _smallBtnClass ;
botBtn . style . cssText = _smallBtnStyle ;
botBtn . addEventListener ( 'click' , ( ) => body . scrollTo ( { top : body . scrollHeight , behavior : 'smooth' } ) ) ;
jumpBar . appendChild ( botBtn ) ;
body . appendChild ( jumpBar ) ;
for ( const p of pages ) {
const sec = document . createElement ( 'div' ) ;
sec . className = 'pdf-export-section' ;
sec . id = ` pdf-export-page- ${ p } ` ;
pageAnchors [ p ] = sec ;
sec . innerHTML = ` <div class="pdf-export-section-title">Page ${ p } </div> ` ;
for ( const f of byPage . get ( p ) ) {
const row = document . createElement ( 'div' ) ;
row . className = 'pdf-export-row' ;
const label = document . createElement ( 'label' ) ;
label . textContent = f . label || f . name ;
label . title = ` ${ f . name } ( ${ f . type } ) ` ;
row . appendChild ( label ) ;
const isSignature = f . type === 'signature' || /sign(?:ed|ature)/i . test ( ( f . name || '' ) + ' ' + ( f . label || '' ) ) ;
const isDate = f . type === 'text' && /\b(date|dated)\b/i . test ( ` ${ f . name || '' } ${ f . label || '' } ` ) ;
let input ;
if ( isSignature ) {
const wrap = document . createElement ( 'div' ) ;
wrap . style . cssText = 'display:flex;align-items:center;gap:8px;' ;
const btn = document . createElement ( 'button' ) ;
btn . className = 'confirm-btn confirm-btn-secondary' ;
btn . style . cssText = 'padding:3px 10px;font-size:0.78rem;' ;
const thumb = document . createElement ( 'img' ) ;
thumb . style . cssText = 'max-height:32px;max-width:140px;object-fit:contain;border:1px solid var(--border);border-radius:3px;background:#fff;display:none;' ;
const clearBtn = document . createElement ( 'button' ) ;
clearBtn . textContent = '× ' ;
clearBtn . title = 'Remove signature from this field' ;
clearBtn . className = 'confirm-btn confirm-btn-secondary' ;
clearBtn . style . cssText = 'padding:0 8px;font-size:0.85rem;line-height:1;display:none;' ;
const apply = ( sig ) => {
wrap . dataset . signatureId = sig . id ;
thumb . src = sig . dataUrl ;
thumb . style . display = '' ;
clearBtn . style . display = '' ;
btn . textContent = 'Change' ;
} ;
const clear = ( ) => {
delete wrap . dataset . signatureId ;
thumb . removeAttribute ( 'src' ) ;
thumb . style . display = 'none' ;
clearBtn . style . display = 'none' ;
btn . textContent = 'Sign here' ;
} ;
btn . textContent = 'Sign here' ;
btn . addEventListener ( 'click' , async ( ) => {
const sig = await signatureModule . pick ( ) ;
if ( sig ) apply ( sig ) ;
} ) ;
clearBtn . addEventListener ( 'click' , clear ) ;
wrap . appendChild ( btn ) ;
wrap . appendChild ( thumb ) ;
wrap . appendChild ( clearBtn ) ;
wrap . dataset . fieldName = f . name ;
wrap . dataset . fieldType = 'signature' ;
const last = signatureModule . getLastUsed && signatureModule . getLastUsed ( ) ;
if ( last ) apply ( last ) ;
input = wrap ;
} else if ( isDate ) {
const wrap = document . createElement ( 'div' ) ;
wrap . style . cssText = 'display:flex;gap:6px;align-items:center;' ;
const ti = document . createElement ( 'input' ) ;
ti . type = 'text' ;
ti . value = f . value == null ? '' : String ( f . value ) ;
ti . className = 'pdf-export-input' ;
ti . style . cssText = 'flex:1;' ;
ti . dataset . fieldName = f . name ;
ti . dataset . fieldType = f . type ;
const today = document . createElement ( 'button' ) ;
today . textContent = 'Today' ;
today . title = "Set to today's date" ;
today . className = 'confirm-btn confirm-btn-secondary' ;
today . style . cssText = 'padding:3px 8px;font-size:0.72rem;' ;
today . addEventListener ( 'click' , ( ) => {
const d = new Date ( ) ;
const dd = String ( d . getDate ( ) ) . padStart ( 2 , '0' ) ;
const mm = String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const yyyy = d . getFullYear ( ) ;
ti . value = ` ${ dd } / ${ mm } / ${ yyyy } ` ;
} ) ;
wrap . appendChild ( ti ) ;
wrap . appendChild ( today ) ;
input = wrap ;
} else if ( f . type === 'checkbox' ) {
input = document . createElement ( 'input' ) ;
input . type = 'checkbox' ;
input . checked = ! ! f . value ;
} else if ( f . type === 'choice' && ( f . options || [ ] ) . length ) {
input = document . createElement ( 'select' ) ;
input . className = 'pdf-export-input' ;
const blank = document . createElement ( 'option' ) ;
blank . value = '' ;
blank . textContent = '— (none) —' ;
input . appendChild ( blank ) ;
for ( const o of f . options ) {
const opt = document . createElement ( 'option' ) ;
opt . value = o ; opt . textContent = o ;
if ( o === f . value ) opt . selected = true ;
input . appendChild ( opt ) ;
}
} else {
input = document . createElement ( 'input' ) ;
input . type = 'text' ;
input . value = f . value == null ? '' : String ( f . value ) ;
input . className = 'pdf-export-input' ;
input . style . cssText = 'width:100%;' ;
}
if ( ! isSignature && ! isDate ) {
input . dataset . fieldName = f . name ;
input . dataset . fieldType = f . type ;
}
row . appendChild ( input ) ;
sec . appendChild ( row ) ;
}
body . appendChild ( sec ) ;
}
const downloadBtn = overlay . querySelector ( '#pdf-export-download' ) ;
downloadBtn . disabled = false ;
downloadBtn . addEventListener ( 'click' , async ( ) => {
const values = { } ;
const signatures = { } ;
for ( const el of overlay . querySelectorAll ( '[data-field-name]' ) ) {
const name = el . dataset . fieldName ;
const ftype = el . dataset . fieldType ;
if ( ftype === 'signature' ) {
if ( el . dataset . signatureId ) signatures [ name ] = el . dataset . signatureId ;
} else if ( ftype === 'checkbox' ) {
values [ name ] = el . checked ;
} else {
values [ name ] = el . value ;
}
}
downloadBtn . disabled = true ;
overlay . querySelector ( '#pdf-export-status' ) . textContent = 'Building PDF…' ;
try {
const r = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /export-pdf ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { values , signatures } ) ,
} ) ;
if ( ! r . ok ) {
const t = await r . text ( ) ;
throw new Error ( t || r . statusText ) ;
}
const blob = await r . blob ( ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
const cd = r . headers . get ( 'Content-Disposition' ) || '' ;
const m = cd . match ( /filename\*?=(?:UTF-8'')?"?([^"';]+)/i ) ;
const _slug = ( s ) => ( s || 'form' ) . replace ( /\.pdf$/i , '' ) . replace ( /\s+/g , '_' ) . replace ( /[^A-Za-z0-9._-]/g , '' ) . replace ( /_+/g , '_' ) . replace ( /^_|_$/g , '' ) || 'form' ;
a . download = ( m && decodeURIComponent ( m [ 1 ] ) ) || ( _slug ( docs . get ( activeDocId ) ? . title ) + '_annotated.pdf' ) ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 1000 ) ;
close ( ) ;
} catch ( e ) {
overlay . querySelector ( '#pdf-export-status' ) . textContent = 'Error: ' + e . message ;
downloadBtn . disabled = false ;
}
} ) ;
} catch ( e ) {
overlay . querySelector ( '#pdf-export-body' ) . innerHTML =
` <div style="color:#c00;">Failed to load preview: ${ ( e && e . message ) || e } </div> ` ;
}
}
// Tracks which form-backed docs the user has toggled into PDF view
// (per-doc, in-memory). Survives switches between docs in the same session.
const _pdfViewState = new Map ( ) ;
const _pdfPaneFieldsByDoc = new Map ( ) ; // docId -> [{name, type, inputEl, ...}]
const _pdfPaneAnnotationsByDoc = new Map ( ) ; // docId -> [{id, page, x, y, w, h, el, wrap}]
const _pdfUndoStackByDoc = new Map ( ) ; // docId -> markdown snapshots
let _pdfPaneSaveTimer = null ;
// Match a freeform-annotation bullet line in the markdown source.
// Coords are percentages of page width/height (0– 100) so they scale with
// however wide the PDF pane is rendered. `kind` and `lh` (line-height)
// are optional for backward compat with earlier annotation formats.
function _annotationRegexGlobal ( ) {
return /^[ \t]*-\s+(.*?)\s*<!--\s*annotation\s+id=([\w-]+)\s+page=(\d+)\s+x=([\d.]+)\s+y=([\d.]+)\s+w=([\d.]+)\s+h=([\d.]+)(?:\s+kind=(\w+))?(?:\s+lh=([\d.]+))?\s*-->[ \t]*$/gm ;
}
// 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 } <!-- annotation id= ${ a . id } page= ${ a . page } x= ${ a . x . toFixed ( 2 ) } y= ${ a . y . toFixed ( 2 ) } w= ${ a . w . toFixed ( 2 ) } h= ${ a . h . toFixed ( 2 ) } kind= ${ kind } lh= ${ lh . toFixed ( 2 ) } --> ` ;
}
// 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 ) ;
} ) ;
}
2026-06-01 12:27:17 +03:00
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 } ` ;
}
2026-05-31 23:58:26 +09:00
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 = '<div style="color:#bbb;font-size:13px;text-align:center;padding:40px;">Loading PDF…</div>' ;
if ( savedPill ) pane . appendChild ( savedPill ) ;
let data ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ docId } /render-pages ` ) ;
2026-06-01 12:27:17 +03:00
if ( ! res . ok ) throw new Error ( await _pdfResponseErrorMessage ( res ) ) ;
2026-05-31 23:58:26 +09:00
data = await res . json ( ) ;
} catch ( e ) {
pane . innerHTML = ` <div style="color:#fbb;padding:40px;text-align:center;">Failed to load PDF view: ${ _escHtml ( e . message || String ( e ) ) } </div> ` ;
if ( savedPill ) pane . appendChild ( savedPill ) ;
return ;
}
if ( docId !== activeDocId ) return ;
pane . innerHTML = '' ;
if ( savedPill ) pane . appendChild ( savedPill ) ;
const fieldRefs = [ ] ;
// 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:<id>` 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:<id>` 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 = ` <svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block;"><path d="M4 12 L10 18 L20 6" fill="none" stroke="#111" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg> ` ;
} else if ( kind === 'signature' ) {
input = document . createElement ( 'div' ) ;
input . style . cssText = ` width:100%;height:100%;box-sizing:border-box;border:1px dashed color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;font-size:10px;color:var(--accent, var(--red)); ` ;
input . textContent = ( ann . value && ann . value . startsWith ( 'signature:' ) ) ? '' : 'Sign here' ;
input . dataset . signatureId = ( ann . value && ann . value . startsWith ( 'signature:' ) ) ? ann . value . slice ( 10 ) : '' ;
} else {
// 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 = '<svg width="14" height="14" viewBox="0 0 10 10" style="display:block;margin:auto;height:100%;"><path d="M2 8 L8 2 M5 8 L8 5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round"/></svg>' ;
let menuBtn = null ;
if ( kind === 'text' ) {
menuBtn = document . createElement ( 'button' ) ;
menuBtn . type = 'button' ;
menuBtn . textContent = '…' ;
menuBtn . title = 'Text annotation options' ;
menuBtn . style . cssText = ` position:absolute;bottom: ${ OFF } px;left: ${ OFF } px;width: ${ HS } px;height: ${ HS } px;padding:0;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);background:#fff;color:var(--accent, var(--red));border-radius:50%;cursor:pointer;font-size:15px;line-height:0.8;display: ${ HIDE } ;font-weight:bold;touch-action:none; ` ;
}
// 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 = `
< div style = "display:flex;align-items:center;gap:6px;" >
< span > Line spacing < / s p a n >
< input type = "range" min = "1" max = "3" step = "0.05" value = "${ann.lineHeight || 1.3}" style = "width:90px;accent-color:var(--accent, var(--red));" / >
< input type = "number" class = "lh-val" min = "0.5" max = "5" step = "0.01" value = "${(ann.lineHeight || 1.3).toFixed(2)}" style = "width:54px;font-size:10px;padding:1px 7px 1px 3px;border:1px solid var(--accent, var(--red));border-radius:3px;text-align:right;accent-color:var(--accent, var(--red));" / >
< / d i v >
< button type = "button" class = "pdf-ann-today" style = "height:22px;padding:0 7px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);background:color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);color:var(--accent, var(--red));border-radius:4px;cursor:pointer;font-size:10px;font-family:inherit;text-align:left;" > Today < / b u t t o n >
` ;
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*field= ${ encName . replace ( /[.*+?^${}()|[\\]\\\\]/g , '\\$&' ) } \\ s+type= \\ w+ \\ s*--> \\ s*) $ ` ,
'm'
) ;
const m = md . match ( re ) ;
if ( ! m ) continue ;
const body = m [ 2 ] ;
let newBody = body ;
if ( ref . type === 'checkbox' ) {
const mark = ref . el . checked ? '[x]' : '[ ]' ;
newBody = body . replace ( /^\s*\[[ xX]\]/ , mark ) ;
} else if ( ref . type === 'choice' ) {
const v = ref . el . value || '_(not selected)_' ;
newBody = body . replace ( /(\][\s]*:[ ]*).*$/ , ` $ 1 ${ v } ` ) ;
} else if ( ref . type === 'signature' ) {
const sid = ref . el . dataset . signatureId || '' ;
const v = sid ? ` signature: ${ sid } ` : '_(unsigned)_' ;
newBody = body . replace ( /(:\*\*[ ]*).*$/ , ` $ 1 ${ v } ` ) ;
} else {
const v = ref . el . value === '' ? '_(empty)_' : ref . el . value ;
newBody = body . replace ( /(:\*\*[ ]*).*$/ , ` $ 1 ${ v } ` ) ;
}
if ( newBody !== body ) {
md = md . replace ( re , ` ${ m [ 1 ] } ${ newBody } ${ m [ 3 ] } ` ) ;
changed ++ ;
}
}
// 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 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>Attach' ;
} else if ( ! _replyable && _copyBtn . dataset . mode !== 'copy' ) {
_copyBtn . dataset . mode = 'copy' ;
_copyBtn . title = 'Copy document' ;
_copyBtn . innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy' ;
}
}
// 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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>' ;
const _penIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>' ;
const _playIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>' ;
const _codeIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' ;
// 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 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>' ;
title = 'Table view' ;
} else if ( _isRenderLang ( lang ) ) {
icon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>' ;
title = 'Preview' ;
} else {
icon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>' ;
title = 'Run' ;
}
if ( runBtn . dataset . lastIcon !== lang ) {
runBtn . innerHTML = icon ;
runBtn . title = title ;
runBtn . dataset . lastIcon = lang ;
}
}
// 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' )
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' ;
const codeTitle = ( lang === 'csv' ) ? 'Edit' : 'Edit code' ;
if ( codeBtn . dataset . lastIcon !== lang ) {
codeBtn . innerHTML = codeIco ;
codeBtn . title = codeTitle ;
codeBtn . dataset . lastIcon = lang ;
}
}
// 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 : '<span style="font-size:12px;font-weight:600;">⊞</span>' ;
actionBtn . title = _csvActive ? 'Edit' : 'Table View' ;
if ( _csvActive ) actionBtn . classList . add ( 'active' ) ;
} else if ( _isRenderLang ( lang ) ) {
// 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 += ` \n Cc: ${ cc } ` ;
if ( bcc ) header += ` \n Bcc: ${ bcc } ` ;
header += ` \n Subject: ${ subject } ` ;
if ( inReplyTo ) header += ` \n In-Reply-To: ${ inReplyTo } ` ;
if ( references ) header += ` \n References: ${ references } ` ;
if ( sourceUid ) header += ` \n X-Source-UID: ${ sourceUid } ` ;
if ( sourceFolder ) header += ` \n X-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 <b>there</b>".)
if ( /<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i . test ( t ) ) return t ;
try { return markdownModule . mdToHtml ( text ) ; }
catch ( _ ) {
const d = document . createElement ( 'div' ) ; d . textContent = text ;
return d . innerHTML . replace ( /\n/g , '<br>' ) ;
}
}
// 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 ;
}
2026-06-02 22:38:55 +09:00
function _captureEmailBodyFocusState ( ) {
const rich = _emailRichbodyActive ( ) ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
const active = document . activeElement ;
if ( rich && ( active === rich || rich . contains ( active ) ) ) {
const sel = window . getSelection ( ) ;
const range = sel && sel . rangeCount ? sel . getRangeAt ( 0 ) : null ;
return {
type : 'rich' ,
range : range && rich . contains ( range . commonAncestorContainer ) ? range . cloneRange ( ) : null ,
} ;
}
if ( ta && active === ta ) {
return {
type : 'textarea' ,
start : ta . selectionStart ,
end : ta . selectionEnd ,
} ;
}
return null ;
}
function _restoreEmailBodyFocusState ( state ) {
if ( ! state ) return ;
requestAnimationFrame ( ( ) => {
if ( state . type === 'rich' ) {
const rich = _emailRichbodyActive ( ) ;
if ( ! rich ) return ;
rich . focus ( { preventScroll : true } ) ;
if ( state . range ) {
const sel = window . getSelection ( ) ;
if ( sel ) {
sel . removeAllRanges ( ) ;
sel . addRange ( state . range ) ;
}
}
} else if ( state . type === 'textarea' ) {
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! ta ) return ;
ta . focus ( { preventScroll : true } ) ;
if ( Number . isFinite ( state . start ) && Number . isFinite ( state . end ) ) {
try { ta . setSelectionRange ( state . start , state . end ) ; } catch ( _ ) { }
}
}
} ) ;
}
2026-06-01 20:56:11 +09:00
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 ) ;
}
2026-05-31 23:58:26 +09:00
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 ) ;
}
}
2026-06-02 22:38:55 +09:00
function _syncEmailHeaderSummary ( ) {
const to = document . getElementById ( 'doc-email-to' ) ? . value ? . trim ( ) || 'No recipient' ;
const subject = document . getElementById ( 'doc-email-subject' ) ? . value ? . trim ( ) || 'No subject' ;
const cc = document . getElementById ( 'doc-email-cc' ) ? . value ? . trim ( ) || '' ;
const bcc = document . getElementById ( 'doc-email-bcc' ) ? . value ? . trim ( ) || '' ;
const summary = document . getElementById ( 'doc-email-collapse-summary' ) ;
if ( ! summary ) return ;
const extras = [ ] ;
if ( cc ) extras . push ( 'Cc' ) ;
if ( bcc ) extras . push ( 'Bcc' ) ;
summary . textContent = ` ${ to } · ${ subject } ${ extras . length ? ` · ${ extras . join ( '/' ) } ` : '' } ` ;
summary . title = summary . textContent ;
}
function _setEmailHeaderCollapsed ( collapsed , { manual = true } = { } ) {
const header = document . getElementById ( 'doc-email-header' ) ;
const btn = document . getElementById ( 'doc-email-collapse-btn' ) ;
if ( ! header ) return ;
if ( window . innerWidth > 768 ) collapsed = false ;
header . classList . toggle ( 'doc-email-header-collapsed' , ! ! collapsed ) ;
if ( btn ) {
btn . setAttribute ( 'aria-expanded' , String ( ! collapsed ) ) ;
btn . title = collapsed ? 'Show email fields' : 'Hide email fields' ;
}
const doc = activeDocId && docs . get ( activeDocId ) ;
if ( doc && manual ) doc . _emailHeaderCollapsed = ! ! collapsed ;
if ( manual && ! collapsed ) _emailHeaderManualExpandUntil = Date . now ( ) + 1400 ;
_syncEmailHeaderSummary ( ) ;
}
function _shouldAutoCollapseEmailHeader ( ) {
return window . innerWidth <= 768 ;
}
function _maybeAutoCollapseEmailHeader ( ) {
const doc = activeDocId && docs . get ( activeDocId ) ;
if ( ! doc || doc . language !== 'email' ) return ;
if ( Date . now ( ) < _emailHeaderManualExpandUntil ) return ;
if ( document . activeElement ? . closest ? . ( '#doc-email-fields' ) ) return ;
if ( _shouldAutoCollapseEmailHeader ( ) ) _setEmailHeaderCollapsed ( true , { manual : false } ) ;
}
2026-05-31 23:58:26 +09:00
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 ;
2026-06-02 22:38:55 +09:00
_setEmailHeaderCollapsed ( ! ! ( doc && doc . _emailHeaderCollapsed ) , { manual : false } ) ;
2026-05-31 23:58:26 +09:00
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 = ` <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span> ${ _escHtml ( att . filename ) } </span><span class="att-size"> ${ sizeKb } </span> ` ;
// 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' : '' ;
2026-06-02 22:38:55 +09:00
_syncEmailHeaderSummary ( ) ;
2026-05-31 23:58:26 +09:00
}
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 = `
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" / > < / s v g >
< span class = "compose-chip-name" > $ { _escHtml ( att . filename ) } < / s p a n >
< span class = "att-size" > $ { sizeKb } < / s p a n >
< button class = "compose-chip-remove" title = "Remove" > × < / b u t t o n >
` ;
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 = ` <span class="contact-name"> ${ _escHtml ( c . name ) } </span><span class="contact-email"> ${ _escHtml ( em ) } </span> ` ;
// 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 = `
< div class = "modal-content" style = "width:360px;max-width:90vw;" >
< div class = "modal-header" > < h4 > No attachments found < / h 4 > < / d i v >
< div class = "modal-body" style = "padding:16px;font-size:13px;opacity:0.8;" >
Your message mentions an attachment , but nothing is attached . Send anyway ?
< / d i v >
< div class = "modal-footer" style = "display:flex;gap:8px;justify-content:flex-end;" >
< button class = "memory-toolbar-btn" id = "att-warn-cancel" > Go back < / b u t t o n >
< button class = "memory-toolbar-btn" id = "att-warn-send" style = "background:var(--accent-primary,var(--red));color:#fff;border-color:var(--accent-primary,var(--red));" > Send anyway < / b u t t o n >
< / d i v >
< / d i v >
` ;
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 ( ) {
2026-06-01 13:42:14 +09:00
const sendDocId = activeDocId ;
2026-05-31 23:58:26 +09:00
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 ( ) ;
2026-06-01 20:56:11 +09:00
if ( _rich ) _syncEmailRichbody ( _rich ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const body = ( _rich ? ( _rich . innerText || _rich . textContent || '' ) : ( textarea ? . value || '' ) ) . trim ( ) ;
2026-05-31 23:58:26 +09:00
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 ;
}
2026-06-01 20:56:11 +09:00
if ( inReplyTo && ! _emailReplyOwnText ( body ) ) {
if ( uiModule ) uiModule . showError ( 'Reply body is empty' ) ;
return ;
}
2026-05-31 23:58:26 +09:00
// 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 = '' ;
2026-06-01 13:42:14 +09:00
let detachedEmailDoc = null ;
2026-05-31 23:58:26 +09:00
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' , {
2026-06-01 20:56:11 +09:00
duration : 3200 ,
leadingIcon : 'spinner' ,
2026-05-31 23:58:26 +09:00
action : 'Cancel' ,
onAction : ( ) => { canceled = true ; } ,
} ) ;
}
2026-06-01 20:56:11 +09:00
await _sleep ( 3000 ) ;
2026-06-01 13:52:07 +09:00
if ( ! canceled ) detachedEmailDoc = _detachActiveEmailForBackground ( sendDocId ) ;
await _sleep ( 200 ) ;
2026-05-31 23:58:26 +09:00
if ( canceled ) {
2026-06-01 13:42:14 +09:00
_restoreDetachedEmailDoc ( detachedEmailDoc ) ;
detachedEmailDoc = null ;
2026-05-31 23:58:26 +09:00
if ( uiModule ) uiModule . showToast ( 'Send canceled' ) ;
return ;
}
const activeAccountId = await _resolveComposeSendAccountId ( ) ;
const res = await fetch ( ` ${ API _BASE } /api/email/send ` , {
method : 'POST' ,
2026-06-01 20:56:11 +09:00
credentials : 'same-origin' ,
2026-05-31 23:58:26 +09:00
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 ,
} ) ,
} ) ;
2026-06-01 20:56:11 +09:00
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 } ) ` ;
2026-05-31 23:58:26 +09:00
if ( data . success ) {
if ( uiModule ) {
uiModule . showToast ( 'Message sent' , {
duration : 7000 ,
2026-06-01 13:52:07 +09:00
leadingIcon : 'check' ,
2026-05-31 23:58:26 +09:00
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 <email>" 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 } } ) ) ;
}
2026-06-01 13:42:14 +09:00
// 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 ( ) ;
2026-05-31 23:58:26 +09:00
} else {
2026-06-01 13:42:14 +09:00
renderTabs ( ) ;
2026-05-31 23:58:26 +09:00
}
2026-06-01 13:42:14 +09:00
_syncDocIndicator ( ) ;
2026-05-31 23:58:26 +09:00
}
} else {
2026-06-01 13:42:14 +09:00
_restoreDetachedEmailDoc ( detachedEmailDoc ) ;
detachedEmailDoc = null ;
2026-05-31 23:58:26 +09:00
if ( uiModule ) uiModule . showError ( data . error || 'Failed to send' ) ;
}
} catch ( e ) {
2026-06-01 13:42:14 +09:00
_restoreDetachedEmailDoc ( detachedEmailDoc ) ;
detachedEmailDoc = null ;
2026-05-31 23:58:26 +09:00
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 ( ) ;
2026-06-01 20:56:11 +09:00
if ( _rich ) _syncEmailRichbody ( _rich ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const body = ( _rich ? ( _rich . innerText || _rich . textContent || '' ) : ( textarea ? . value || '' ) ) . trim ( ) ;
2026-05-31 23:58:26 +09:00
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 ) ;
}
2026-06-01 13:42:14 +09:00
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 } } ;
2026-06-02 22:38:55 +09:00
const wasActive = activeDocId === docId ;
if ( wasActive ) saveDocument ( { silent : true } ) . catch ( ( ) => { } ) ;
2026-06-01 13:42:14 +09:00
const visibleBefore = _visibleDocIdsForCurrentSession ( ) ;
const idx = visibleBefore . indexOf ( docId ) ;
docs . delete ( docId ) ;
2026-06-02 22:38:55 +09:00
if ( wasActive ) activeDocId = null ;
2026-06-01 13:42:14 +09:00
2026-06-02 22:38:55 +09:00
if ( wasActive ) {
const remaining = visibleBefore . filter ( id => id !== docId && docs . has ( id ) ) ;
const nextId = remaining [ idx ] || remaining [ idx - 1 ] || remaining [ 0 ] || null ;
if ( nextId ) {
switchToDoc ( nextId ) ;
} else {
closePanel ( ) ;
}
2026-06-01 13:42:14 +09:00
}
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 ( ) ;
}
2026-05-31 23:58:26 +09:00
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 || '' ;
2026-06-01 20:56:11 +09:00
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 ;
} ;
2026-05-31 23:58:26 +09:00
// 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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Drafting...' ; }
try {
const res = await fetch ( ` ${ API _BASE } /api/email/ai-reply ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
to : to ,
subject : subject ,
original _body : currentBody ,
model : currentModel ,
session _id : currentSessionId ,
2026-06-01 20:56:11 +09:00
message _id : inReplyTo ,
uid : sourceUid ,
folder : sourceFolder ,
fast : shouldUseFastAiReply ( ) ,
2026-05-31 23:58:26 +09:00
} ) ,
} ) ;
const data = await res . json ( ) ;
if ( data . success && data . reply ) {
2026-06-01 20:56:11 +09:00
const cleanReply = cleanAiReplyText ( data . reply ) ;
2026-05-31 23:58:26 +09:00
const lines = currentBody . split ( '\n' ) ;
const quoteIdx = lines . findIndex ( l => l . startsWith ( 'On ' ) && l . includes ( ' wrote:' ) ) ;
2026-06-01 20:56:11 +09:00
let newBody = '' ;
2026-05-31 23:58:26 +09:00
if ( quoteIdx > 0 ) {
2026-06-01 20:56:11 +09:00
newBody = cleanReply + '\n\n' + lines . slice ( quoteIdx ) . join ( '\n' ) ;
2026-05-31 23:58:26 +09:00
} else {
2026-06-01 20:56:11 +09:00
newBody = cleanReply + ( currentBody ? '\n\n' + currentBody : '' ) ;
2026-05-31 23:58:26 +09:00
}
2026-06-01 20:56:11 +09:00
await _streamEmailBodyText ( textarea , newBody ) ;
2026-05-31 23:58:26 +09:00
if ( uiModule ) uiModule . showToast ( ` AI draft inserted ( ${ data . model _used || 'AI' } ) ` ) ;
} else {
if ( uiModule ) uiModule . showError ( data . error || 'Failed to generate reply' ) ;
}
} catch ( e ) {
if ( uiModule ) uiModule . showError ( 'Failed to generate AI reply' ) ;
} finally {
if ( btn ) { btn . disabled = false ; btn . innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Reply' ; }
}
}
async function _scheduleSend ( anchorEl = null ) {
const to = document . getElementById ( 'doc-email-to' ) ? . value ? . trim ( ) ;
const cc = document . getElementById ( 'doc-email-cc' ) ? . value ? . trim ( ) || '' ;
const bcc = document . getElementById ( 'doc-email-bcc' ) ? . value ? . trim ( ) || '' ;
const subject = document . getElementById ( 'doc-email-subject' ) ? . value ? . trim ( ) ;
const inReplyTo = document . getElementById ( 'doc-email-in-reply-to' ) ? . value ? . trim ( ) ;
const references = document . getElementById ( 'doc-email-references' ) ? . value ? . trim ( ) ;
2026-06-01 20:56:11 +09:00
const _rich = _emailRichbodyActive ( ) ;
if ( _rich ) _syncEmailRichbody ( _rich ) ;
const body = ( _rich
? ( _rich . innerText || _rich . textContent || '' )
: ( document . getElementById ( 'doc-editor-textarea' ) ? . value || '' )
) . trim ( ) ;
2026-05-31 23:58:26 +09:00
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 ;
}
2026-06-01 20:56:11 +09:00
if ( inReplyTo && ! _emailReplyOwnText ( body ) ) {
if ( uiModule ) uiModule . showError ( 'Reply body is empty' ) ;
return ;
}
2026-05-31 23:58:26 +09:00
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 = `
< div class = "modal-content schedule-send-modal" style = "width:400px;max-width:92vw;" >
< div class = "modal-header" >
< h4 > Schedule Send < / h 4 >
< button class = "close-btn" id = "sched-close" title = "Close" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / s v g > < / b u t t o n >
< / d i v >
< div class = "modal-body schedule-send-body" >
< label class = "schedule-send-label" > Quick presets < / l a b e l >
< div class = "schedule-send-presets" >
< button class = "memory-toolbar-btn" data - preset = "1h" > In 1 hour < / b u t t o n >
< button class = "memory-toolbar-btn" data - preset = "3h" > In 3 hours < / b u t t o n >
< button class = "memory-toolbar-btn" data - preset = "tomorrow" > Tomorrow 9 am < / b u t t o n >
< button class = "memory-toolbar-btn" data - preset = "monday" > Monday 9 am < / b u t t o n >
< / d i v >
< label class = "schedule-send-label" for = "sched-datetime" > Or pick a specific time < / l a b e l >
< input type = "datetime-local" id = "sched-datetime" class = "schedule-send-datetime" / >
< / d i v >
< div class = "modal-footer schedule-send-footer" >
< button class = "memory-toolbar-btn" id = "sched-cancel" > Cancel < / b u t t o n >
< button class = "memory-toolbar-btn schedule-send-confirm" id = "sched-confirm" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < circle cx = "12" cy = "12" r = "10" / > < polyline points = "12 6 12 12 16 14" / > < / s v g > S c h e d u l e < / b u t t o n >
< / d i v >
< / d i v >
` ;
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 = '<button type="button" class="doc-divider-collapse" title="Collapse panel" data-mode="collapse"><span>› </span></button>' +
'<button type="button" class="doc-divider-hide" title="Hide panel" aria-label="Hide panel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>' ;
const _divHide = divider . querySelector ( '.doc-divider-hide' ) ;
if ( _divHide ) {
_divHide . addEventListener ( 'mousedown' , ( e ) => e . stopPropagation ( ) ) ;
_divHide . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; closePanel ( 'down' ) ; } ) ;
}
// 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 = `
< input type = "hidden" id = "doc-title-input" value = "" / >
< div class = "doc-mobile-grabber" id = "doc-mobile-grabber" aria - hidden = "true" > < / d i v >
< div class = "doc-editor-header" id = "doc-editor-actions" >
< button id = "doc-undo-btn" class = "doc-action-icon-btn" title = "Undo (Ctrl+Z)" style = "gap:4px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "1 4 1 10 7 10" / > < path d = "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" / > < / s v g > < s p a n s t y l e = " f o n t - s i z e : 1 1 p x ; " > U n d o < / s p a n > < / b u t t o n >
< button id = "doc-header-preview-btn" class = "doc-action-icon-btn" title = "Run / Preview" style = "display:none;opacity:0.85;gap:4px;" > < / b u t t o n >
< span id = "doc-stream-indicator" class = "doc-stream-indicator" style = "display:none" > < span class = "doc-stream-dot" > < / s p a n > e d i t i n g < / s p a n >
< span id = "doc-version-badge" class = "doc-version-badge" title = "Version history" style = "display:none" > v1 < / s p a n >
< span style = "flex:1" > < / s p a n >
< button id = "doc-export-pdf-btn" class = "doc-action-icon-btn" title = "Export PDF" style = "display:none;opacity:0.7;gap:4px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < line x1 = "12" y1 = "18" x2 = "12" y2 = "12" / > < polyline points = "9 15 12 18 15 15" / > < / s v g > < s p a n s t y l e = " f o n t - s i z e : 1 1 p x ; " > E x p o r t P D F < / s p a n > < / b u t t o n >
< button id = "doc-pdf-view-btn" class = "doc-action-icon-btn" title = "Toggle PDF view" style = "display:none;opacity:0.7;gap:4px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < / s v g > < s p a n s t y l e = " f o n t - s i z e : 1 1 p x ; " > P D F < / s p a n > < / b u t t o n >
< select id = "doc-language-select" class = "doc-language-select" >
< option value = "" > type < / o p t i o n >
< option value = "python" > python < / o p t i o n >
< option value = "javascript" > javascript < / o p t i o n >
< option value = "typescript" > typescript < / o p t i o n >
< option value = "html" > html < / o p t i o n >
< option value = "css" > css < / o p t i o n >
< option value = "markdown" > markdown < / o p t i o n >
< option value = "json" > json < / o p t i o n >
< option value = "yaml" > yaml < / o p t i o n >
< option value = "bash" > bash < / o p t i o n >
< option value = "sql" > sql < / o p t i o n >
< option value = "rust" > rust < / o p t i o n >
< option value = "go" > go < / o p t i o n >
< option value = "java" > java < / o p t i o n >
< option value = "c" > c < / o p t i o n >
< option value = "cpp" > c ++ < / o p t i o n >
< option value = "csharp" > c # < / o p t i o n >
< option value = "xml" > xml < / o p t i o n >
< option value = "svg" > svg < / o p t i o n >
< option value = "toml" > toml < / o p t i o n >
< option value = "ini" > ini < / o p t i o n >
< option value = "ruby" > ruby < / o p t i o n >
< option value = "php" > php < / o p t i o n >
< option value = "csv" > csv < / o p t i o n >
< option value = "email" > email < / o p t i o n >
< option value = "pdf" > pdf < / o p t i o n >
< / s e l e c t >
<!-- Close + Copy / Export moved to the bottom action footer ( # doc - actions - footer )
so regular docs match the email footer layout . -- >
< / d i v >
< div class = "doc-tab-bar" id = "doc-tab-bar" > < / d i v >
< div id = "doc-email-header" class = "doc-email-header" style = "display:none" >
2026-06-02 22:38:55 +09:00
< button type = "button" id = "doc-email-collapse-btn" class = "doc-email-collapse-btn" title = "Hide email fields" aria - expanded = "true" >
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.5" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "6 15 12 9 18 15" / > < / s v g >
< span id = "doc-email-collapse-summary" class = "doc-email-collapse-summary" > No recipient · No subject < / s p a n >
< / b u t t o n >
< div id = "doc-email-fields" class = "doc-email-fields" >
< div class = "email-field" style = "position:relative" >
< label > To < / l a b e l >
< input type = "text" id = "doc-email-to" placeholder = "recipient@example.com" autocomplete = "off" / >
< div id = "doc-email-to-suggestions" class = "email-autocomplete" style = "display:none" > < / d i v >
< button type = "button" id = "doc-email-show-cc" class = "email-cc-toggle" title = "Show Cc/Bcc" > Cc < / b u t t o n >
< / d i v >
< div class = "email-field" id = "doc-email-cc-row" style = "display:none;position:relative" >
< label > Cc < / l a b e l >
< input type = "text" id = "doc-email-cc" placeholder = "cc@example.com" autocomplete = "off" / >
< div id = "doc-email-cc-suggestions" class = "email-autocomplete" style = "display:none" > < / d i v >
< / d i v >
< div class = "email-field" id = "doc-email-bcc-row" style = "display:none;position:relative" >
< label > Bcc < / l a b e l >
< input type = "text" id = "doc-email-bcc" placeholder = "bcc@example.com" autocomplete = "off" / >
< div id = "doc-email-bcc-suggestions" class = "email-autocomplete" style = "display:none" > < / d i v >
< / d i v >
< div class = "email-field" > < label > Subject < /label><input type="text" id="doc-email-subject" placeholder="Subject" / > < / d i v >
< div id = "doc-email-attachments" class = "email-attachments" style = "display:none" > < / d i v >
< div id = "doc-email-compose-atts" class = "email-compose-atts" style = "display:none" > < / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
< input type = "hidden" id = "doc-email-in-reply-to" / >
< input type = "hidden" id = "doc-email-references" / >
< input type = "hidden" id = "doc-email-source-uid" / >
< input type = "hidden" id = "doc-email-source-folder" / >
< input type = "file" id = "doc-email-file-input" multiple style = "display:none" / >
< / d i v >
< div class = "doc-md-toolbar" id = "doc-md-toolbar" style = "display:none" >
< div class = "md-toolbar-items" id = "md-toolbar-items" >
< span class = "md-view-toggle" id = "doc-md-view-toggle" style = "display:none" role = "group" aria - label = "Edit or preview" >
< button type = "button" class = "md-view-opt" data - mdview = "edit" title = "Edit source" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" / > < path d = "M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" / > < / s v g > < / b u t t o n >
< button type = "button" class = "md-view-opt" data - mdview = "preview" title = "Preview" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < circle cx = "12" cy = "12" r = "3" / > < / s v g > < / b u t t o n >
< / s p a n >
< span class = "md-view-toggle" id = "doc-render-view-toggle" style = "display:none" role = "group" aria - label = "Code or run" >
< button type = "button" class = "md-view-opt" data - renderview = "code" title = "Edit code" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "16 18 22 12 16 6" / > < polyline points = "8 6 2 12 8 18" / > < / s v g > < / b u t t o n >
< button type = "button" class = "md-view-opt" data - renderview = "run" title = "Run / Preview" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "currentColor" stroke = "none" > < polygon points = "5 3 19 12 5 21 5 3" / > < / s v g > < / b u t t o n >
< / s p a n >
< button id = "doc-fontsize-btn" class = "doc-action-icon-btn" title = "Font size" style = "position:relative;width:28px;height:26px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" style = "opacity:0.7;" > < path d = "M4 7V4h16v3" / > < path d = "M12 4v16" / > < path d = "M8 20h8" / > < /svg><span class="doc-fontsize-levels"><i data-sz="s">S</i > < i data - sz = "m" > M < /i><i data-sz="l">L</i > < / s p a n > < / b u t t o n >
< button id = "doc-diff-toggle-btn" class = "doc-action-icon-btn" title = "Compare changes" style = "opacity:0.7;display:none;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M12 3v18" / > < path d = "M5 12H2l5-5 5 5H9" / > < path d = "M19 12h3l-5 5-5-5h3" / > < / s v g > < / b u t t o n >
< span class = "md-toolbar-sep" > < / s p a n >
< button type = "button" data - md = "bold" title = "Bold (Ctrl+B)" > < b > B < / b > < / b u t t o n >
< button type = "button" data - md = "italic" title = "Italic (Ctrl+I)" > < i > I < / i > < / b u t t o n >
< button type = "button" data - md = "strike" title = "Strikethrough" > < s > S < / s > < / b u t t o n >
< span class = "md-toolbar-sep" > < / s p a n >
< button type = "button" class = "md-dd-toggle" data - dd = "heading" title = "Heading" > < b > H < /b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/ > < / s v g > < / b u t t o n >
< button type = "button" class = "md-dd-toggle" data - dd = "list" title = "List" > < span style = "font-variant-numeric:tabular-nums;" > 1. < /span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/ > < / s v g > < / b u t t o n >
< span class = "md-toolbar-sep" > < / s p a n >
< button type = "button" data - md = "link" title = "Link" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" / > < path d = "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" / > < / s v g > < / b u t t o n >
< button type = "button" id = "md-toolbar-attach-btn" class = "md-toolbar-attach-btn" title = "Attach files" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" / > < / s v g > < / b u t t o n >
< button type = "button" class = "md-dd-toggle md-toolbar-email-hide" data - dd = "code" title = "Code" > \ ` <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
< button type = "button" data - md = "hr" title = "Horizontal rule" > — < / b u t t o n >
< span class = "md-toolbar-sep" > < / s p a n >
< span id = "md-toolbar-emoji-slot" > < / s p a n >
< span class = "md-toolbar-sep md-toolbar-pdf-only" style = "display:none" > < / s p a n >
< button type = "button" id = "doc-pdf-add-text-btn" class = "md-toolbar-pdf-only" title = "Add text box (then click on PDF)" style = "display:none" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "4 7 4 4 20 4 20 7" / > < line x1 = "9" y1 = "20" x2 = "15" y2 = "20" / > < line x1 = "12" y1 = "4" x2 = "12" y2 = "20" / > < / s v g > < / b u t t o n >
< button type = "button" id = "doc-pdf-add-check-btn" class = "md-toolbar-pdf-only" title = "Add checkmark (then click on PDF)" style = "display:none" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "20 6 9 17 4 12" / > < / s v g > < / b u t t o n >
< button type = "button" id = "doc-pdf-add-sign-btn" class = "md-toolbar-pdf-only" title = "Add signature (then click on PDF)" style = "display:none" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M15 3l6 6-9 9-3-3z" / > < path d = "M9 15l-3 1 1-3" / > < path d = "M4 18l3-3" / > < path d = "M3 20l3-3" / > < path d = "M5 22l3-3" / > < / s v g > < s p a n c l a s s = " d o c - p d f - s i g n - l a b e l " > s i g n < / s p a n > < / b u t t o n >
< button type = "button" id = "doc-pdf-refresh-btn" class = "md-toolbar-pdf-only" title = "Reload PDF view" style = "display:none" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "23 4 23 10 17 10" / > < path d = "M20.49 15a9 9 0 1 1-2.12-9.36L23 10" / > < / s v g > < / b u t t o n >
< / d i v >
< div class = "md-toolbar-overflow-wrapper" id = "md-toolbar-overflow-wrapper" style = "display:none" >
< button class = "md-toolbar-overflow-toggle" id = "md-toolbar-overflow-toggle" title = "More formatting" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "currentColor" > < circle cx = "12" cy = "5" r = "2" / > < circle cx = "12" cy = "12" r = "2" / > < circle cx = "12" cy = "19" r = "2" / > < / s v g > < / b u t t o n >
< div class = "md-toolbar-overflow-menu" id = "md-toolbar-overflow-menu" > < / d i v >
< / d i v >
< button type = "button" class = "md-scroll-arrow md-scroll-left" id = "md-scroll-left" title = "Scroll left" style = "display:none" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.5" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "15 18 9 12 15 6" / > < / s v g > < / b u t t o n >
< button type = "button" class = "md-scroll-arrow md-scroll-right" id = "md-scroll-right" title = "Scroll right" style = "display:none" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.5" stroke - linecap = "round" stroke - linejoin = "round" > < polyline points = "9 18 15 12 9 6" / > < / s v g > < / b u t t o n >
< / d i v >
< div id = "doc-find-bar" class = "doc-find-bar" style = "display:none" >
< input id = "doc-find-input" class = "doc-find-input" type = "text" placeholder = "Find..." / >
< span id = "doc-find-count" class = "doc-find-count" > < / s p a n >
< button id = "doc-find-prev" class = "doc-find-nav" title = "Previous" > & uarr ; < / b u t t o n >
< button id = "doc-find-next" class = "doc-find-nav" title = "Next" > & darr ; < / b u t t o n >
< button id = "doc-find-close" class = "doc-find-close" title = "Close" > & times ; < / b u t t o n >
< / d i v >
< div id = "doc-editor-wrap" class = "doc-editor-wrap" >
< div id = "doc-line-numbers" class = "doc-line-numbers" > 1 < / d i v >
< pre id = "doc-editor-highlight" class = "doc-editor-highlight" > < code id = "doc-editor-code" > < / c o d e > < / p r e >
< textarea id = "doc-editor-textarea" class = "doc-editor-textarea" placeholder = "Document content..." spellcheck = "false" > < / t e x t a r e a >
< / d i v >
<!-- WYSIWYG email body . In email mode this replaces the source editor :
B / I / S act on the live text ( execCommand ) , and on send its HTML becomes
the email ' s HTML part . Its plain text is mirrored into the textarea so
the existing send / draft / change - detection paths keep working . -- >
< div id = "doc-email-richbody" class = "doc-email-richbody" contenteditable = "true" spellcheck = "true" style = "display:none" data - no - swipe - dismiss > < / d i v >
< div id = "doc-email-actions" class = "doc-email-actions" style = "display:none" >
< button id = "doc-email-discard-btn" class = "email-discard-btn" title = "Close email" style = "display:inline-flex;align-items:center;gap:5px;" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.2" stroke - linecap = "round" aria - hidden = "true" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / s v g > < s p a n > C l o s e < / s p a n > < / b u t t o n >
< span style = "flex:1" > < / s p a n >
< div class = "email-send-split" >
< button id = "doc-email-send-btn" class = "email-send-btn email-send-main" title = "Send email (Ctrl+Enter)" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" aria - hidden = "true" > < line x1 = "22" y1 = "2" x2 = "11" y2 = "13" / > < polygon points = "22 2 15 22 11 13 2 9 22 2" / > < / s v g > S e n d < / b u t t o n >
< button id = "doc-email-send-caret" class = "email-send-btn email-send-caret" title = "More send options" aria - haspopup = "true" aria - expanded = "false" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.6" stroke - linecap = "round" stroke - linejoin = "round" aria - hidden = "true" > < polyline points = "6 9 12 15 18 9" / > < / s v g > < / b u t t o n >
< div id = "doc-email-more-menu" class = "email-more-menu" style = "display:none" >
< div class = "dropdown-item-compact" id = "doc-email-draft-btn" > < span class = "dropdown-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" / > < polyline points = "17 21 17 13 7 13 7 21" / > < polyline points = "7 3 7 8 15 8" / > < / s v g > < / s p a n > S a v e D r a f t < / d i v >
< div class = "dropdown-item-compact" id = "doc-email-schedule-btn" > < span class = "dropdown-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < circle cx = "12" cy = "12" r = "10" / > < polyline points = "12 6 12 12 16 14" / > < / s v g > < / s p a n > S c h e d u l e S e n d . . . < / d i v >
< div class = "dropdown-item-compact" id = "doc-email-unread-btn" > < span class = "dropdown-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < circle cx = "12" cy = "12" r = "10" / > < circle cx = "12" cy = "12" r = "3" fill = "currentColor" / > < / s v g > < / s p a n > M a r k U n r e a d < / d i v >
< / d i v >
< / d i v >
< / d i v >
< div id = "doc-md-preview" class = "doc-md-preview" style = "display:none" > < / d i v >
< div id = "doc-csv-preview" class = "doc-csv-preview" style = "display:none" > < / d i v >
< iframe id = "doc-html-preview" class = "doc-html-preview" sandbox = "allow-scripts allow-modals" style = "display:none" > < / i f r a m e >
< div id = "doc-pdf-view" style = "display:none;width:100%;flex:1;min-height:0;overflow:auto;background:#525659;padding:20px 0;position:relative;" >
< div id = "doc-pdf-save-pill" style = "display:none;position:absolute;top:8px;right:14px;padding:4px 10px;border-radius:12px;font-size:11px;z-index:5;pointer-events:none;background:transparent;color:transparent;" > < / d i v >
< / d i v >
<!-- Action footer sits AFTER all the content / preview panes so it stays
pinned to the bottom no matter which pane ( editor / md - preview /
csv / html / pdf ) is the one growing to fill . -- >
< div id = "doc-actions-footer" class = "doc-email-actions" >
< span class = "email-send-split" id = "doc-copy-export-split" >
< button type = "button" id = "doc-footer-copy-btn" class = "email-send-btn email-send-main" title = "Copy document" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" aria - hidden = "true" > < rect x = "9" y = "9" width = "13" height = "13" rx = "2" / > < path d = "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" / > < / s v g > C o p y < / b u t t o n >
< button type = "button" id = "doc-footer-export-btn" class = "email-send-btn email-send-caret" title = "Export as…" aria - label = "Export options" > < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.5" stroke - linecap = "round" stroke - linejoin = "round" aria - hidden = "true" > < polyline points = "6 15 12 9 18 15" / > < / s v g > < / b u t t o n >
< / s p a n >
< / d i v >
< div id = "doc-version-panel" class = "doc-version-panel hidden" >
< div class = "doc-version-header" >
< span > Version History < / s p a n >
< button id = "doc-version-close" class = "doc-action-icon-btn" title = "Close" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.5" stroke - linecap = "round" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / s v g > < / b u t t o n >
< / d i v >
< div id = "doc-version-list" class = "doc-version-list" > < / d i v >
< / d i v >
< div id = "doc-mobile-footer" class = "doc-mobile-footer" >
< button id = "doc-mobile-close" class = "doc-mobile-footer-btn" type = "button" > Unlink < / b u t t o n >
< span style = "flex:1" > < / s p a n >
< button id = "doc-mobile-copy" class = "doc-mobile-footer-btn" type = "button" > Copy < / b u t t o n >
< / d i v >
` ;
// 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 <button> (it doesn't blur
// the focused textarea), so it lingers after you've typed. Dismiss it on
// any footer control tap.
if ( _footer ) _footer . addEventListener ( 'pointerdown' , ( e ) => {
if ( ! e . target . closest ( 'button, select' ) ) return ;
const _ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( _ta && document . activeElement === _ta ) _ta . blur ( ) ;
} ) ;
}
// Insert after chat-container (appears on right by default)
// If sidebar is on the right, insert before chat-container instead
const sidebar = document . getElementById ( 'sidebar' ) ;
const isRight = sidebar && sidebar . classList . contains ( 'right-side' ) ;
if ( isRight ) {
pane . classList . add ( 'doc-left' ) ;
container . parentNode . insertBefore ( pane , container ) ;
container . parentNode . insertBefore ( divider , container ) ;
} else {
pane . classList . remove ( 'doc-left' ) ;
container . after ( divider ) ;
divider . after ( pane ) ;
}
// Slide-in animation from the correct side
const fromLeft = pane . classList . contains ( 'doc-left' ) ;
pane . style . transform = fromLeft ? 'translateX(-40px)' : 'translateX(40px)' ;
pane . style . opacity = '0' ;
requestAnimationFrame ( ( ) => {
pane . style . transition = 'transform 0.15s cubic-bezier(0.22,1,0.36,1), opacity 0.12s ease-out' ;
pane . style . transform = 'translateX(0)' ;
pane . style . opacity = '1' ;
pane . addEventListener ( 'transitionend' , ( ) => {
pane . style . transition = '' ;
pane . style . transform = '' ;
pane . style . opacity = '' ;
} , { once : true } ) ;
} ) ;
// Wire up divider drag to resize
initDividerDrag ( divider , pane , isRight ) ;
// Divider chevron — single button with three modes (the glyph is the
// same `› ` in markup; CSS rotates 180° for the left-pointing variant).
// • cursor INSIDE the doc pane → collapse (› , slide back, closes panel)
// • cursor OUTSIDE the doc pane → fullscreen (‹ , slide outward, expands)
// • already fullscreen → unfullscreen (› , points back in)
// The user can also drag the chevron vertically along the divider to
// reposition it.
const _divCollapse = divider . querySelector ( '.doc-divider-collapse' ) ;
if ( _divCollapse ) {
_divCollapse . addEventListener ( 'mousedown' , ( e ) => e . stopPropagation ( ) ) ;
let _dragging = false ;
_divCollapse . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( _dragging ) { _dragging = false ; return ; } // suppress click after drag
const mode = _divCollapse . dataset . mode ;
if ( mode === 'fullscreen' || mode === 'unfullscreen' ) toggleFullscreen ( ) ;
else closePanel ( 'down' ) ;
} ) ;
const HYSTERESIS = 24 ;
const _applyMode = ( ev ) => {
// Fullscreen state takes precedence — once the pane is fullscreen the
// chevron always offers the "exit fullscreen" affordance regardless
// of cursor position.
const isFull = pane . classList . contains ( 'doc-fullscreen' ) ;
if ( isFull ) {
if ( _divCollapse . dataset . mode !== 'unfullscreen' ) {
_divCollapse . dataset . mode = 'unfullscreen' ;
_divCollapse . title = 'Exit fullscreen' ;
}
return ;
}
if ( ! ev ) return ;
const rect = divider . getBoundingClientRect ( ) ;
const midX = ( rect . left + rect . right ) / 2 ;
const cur = _divCollapse . dataset . mode ;
if ( ev . clientX > midX + HYSTERESIS && cur !== 'collapse' ) {
_divCollapse . dataset . mode = 'collapse' ;
_divCollapse . title = 'Collapse panel' ;
} else if ( ev . clientX < midX - HYSTERESIS && cur !== 'fullscreen' ) {
_divCollapse . dataset . mode = 'fullscreen' ;
_divCollapse . title = 'Fullscreen' ;
}
} ;
const _onMove = ( ev ) => _applyMode ( ev ) ;
document . addEventListener ( 'pointermove' , _onMove , { passive : true } ) ;
// Reflect the fullscreen state immediately on toggle (no cursor move).
const _classObs = new MutationObserver ( ( ) => _applyMode ( ) ) ;
_classObs . observe ( pane , { attributes : true , attributeFilter : [ 'class' ] } ) ;
// Drag-to-reposition: hold + drag vertically moves the chevron along
// the divider. Stored as a percent so resizing the pane keeps it
// proportional. Only kicks in after a small movement so a normal tap
// still registers as a click.
const DRAG _THRESHOLD = 4 ;
let _startY = 0 , _moved = false , _pid = null ;
_divCollapse . addEventListener ( 'pointerdown' , ( ev ) => {
if ( ev . button !== 0 && ev . pointerType === 'mouse' ) return ;
_startY = ev . clientY ;
_moved = false ;
_pid = ev . pointerId ;
_divCollapse . setPointerCapture ? . ( _pid ) ;
ev . preventDefault ( ) ;
} ) ;
_divCollapse . addEventListener ( 'pointermove' , ( ev ) => {
if ( _pid === null ) return ;
const dy = ev . clientY - _startY ;
if ( ! _moved && Math . abs ( dy ) < DRAG _THRESHOLD ) return ;
_moved = true ;
_dragging = true ;
const rect = divider . getBoundingClientRect ( ) ;
if ( ! rect . height ) return ;
const pct = Math . max ( 6 , Math . min ( 94 , ( ( ev . clientY - rect . top ) / rect . height ) * 100 ) ) ;
_divCollapse . style . top = pct + '%' ;
} ) ;
const _endDrag = ( ) => {
if ( _pid !== null ) {
try { _divCollapse . releasePointerCapture ? . ( _pid ) ; } catch { }
_pid = null ;
}
} ;
_divCollapse . addEventListener ( 'pointerup' , _endDrag ) ;
_divCollapse . addEventListener ( 'pointercancel' , _endDrag ) ;
const _obs = new MutationObserver ( ( ) => {
if ( ! document . body . contains ( divider ) ) {
document . removeEventListener ( 'pointermove' , _onMove ) ;
_classObs . disconnect ( ) ;
_obs . disconnect ( ) ;
}
} ) ;
_obs . observe ( document . body , { childList : true , subtree : true } ) ;
}
// Mobile grab handle — swipe down to dismiss (like the other sheet windows).
_wireSwipeDismiss ( document . getElementById ( 'doc-mobile-grabber' ) ) ;
document . getElementById ( 'doc-mobile-grabber' ) ? . addEventListener ( 'click' , ( ) => closePanel ( 'down' ) ) ;
// Wire up events
document . getElementById ( 'doc-close-btn' ) ? . addEventListener ( 'click' , ( ) => closePanel ( 'down' ) ) ;
document . getElementById ( 'doc-footer-close-btn' ) ? . addEventListener ( 'click' , ( ) => { if ( activeDocId ) closeTab ( activeDocId ) ; } ) ;
document . getElementById ( 'doc-import-btn' ) ? . addEventListener ( 'click' , ( ) => openLibrary ( ) ) ;
document . getElementById ( 'doc-footer-copy-btn' ) ? . addEventListener ( 'click' , ( e ) => {
if ( e . currentTarget . dataset . mode === 'reply' ) { if ( activeDocId ) _sendSignedReply ( activeDocId ) ; }
else copyDocument ( ) ;
} ) ;
document . getElementById ( 'doc-footer-export-btn' ) ? . addEventListener ( 'click' , ( e ) => showExportMenu ( null , e . currentTarget . getBoundingClientRect ( ) ) ) ;
// Mobile footer: Close the current doc + Copy its content (replaces the
// per-tab × on small screens, mirroring the email reader's Close footer).
document . getElementById ( 'doc-mobile-close' ) ? . addEventListener ( 'click' , ( ) => { if ( activeDocId ) closeTab ( activeDocId ) ; } ) ;
document . getElementById ( 'doc-mobile-copy' ) ? . addEventListener ( 'click' , ( ) => copyDocument ( ) ) ;
// Save, copy, run, export, delete, preview toggles are now in per-tab context menu
document . getElementById ( 'doc-version-badge' ) . addEventListener ( 'click' , toggleVersionHistory ) ;
document . getElementById ( 'doc-version-close' ) . addEventListener ( 'click' , _closeVersionPanel ) ;
// Reflect the current language as a small icon left of the type select.
const _syncLangIcon = ( ) => {
const iconEl = document . getElementById ( 'doc-language-icon' ) ;
const v = document . getElementById ( 'doc-language-select' ) ? . value || '' ;
if ( iconEl ) iconEl . innerHTML = v ? langIcon ( v , 14 , { style : 'opacity:0.75;' } ) : '' ;
} ;
// Intercept programmatic `langSelect.value = …` so the icon updates without
// having to instrument every set-site in this file.
( function _interceptLangSelectValue ( ) {
const ls = document . getElementById ( 'doc-language-select' ) ;
if ( ! ls ) return ;
const desc = Object . getOwnPropertyDescriptor ( HTMLSelectElement . prototype , 'value' ) ;
if ( ! desc || ! desc . set ) return ;
Object . defineProperty ( ls , 'value' , {
configurable : true ,
get ( ) { return desc . get . call ( this ) ; } ,
set ( v ) { desc . set . call ( this , v ) ; _syncLangIcon ( ) ; _syncLangPicker ( ) ; } ,
} ) ;
_syncLangIcon ( ) ; // initial paint
} ) ( ) ;
// ── Custom language picker ────────────────────────────────────────────
// Native <option> can't render SVG. So we build a custom dropdown that
// shows each language's icon + label, while keeping the underlying
// <select> in place as the source of truth (all existing code that
// reads/writes langSelect.value keeps working). The native select is
// visually-hidden but still focusable for accessibility / keyboard.
let _syncLangPicker = ( ) => { } ;
( function _initLangPicker ( ) {
const ls = document . getElementById ( 'doc-language-select' ) ;
if ( ! ls || ls . dataset . pickerWired === '1' ) return ;
ls . dataset . pickerWired = '1' ;
const trigger = document . createElement ( 'button' ) ;
trigger . type = 'button' ;
trigger . id = 'doc-langpicker-trigger' ;
trigger . className = 'doc-langpicker-trigger' ;
trigger . setAttribute ( 'aria-haspopup' , 'listbox' ) ;
trigger . setAttribute ( 'aria-expanded' , 'false' ) ;
const menu = document . createElement ( 'div' ) ;
menu . id = 'doc-langpicker-menu' ;
menu . className = 'doc-langpicker-menu' ;
menu . setAttribute ( 'role' , 'listbox' ) ;
menu . style . display = 'none' ;
// Build the menu rows from the <select>'s real <option>s — single
// source of truth, future additions to the select auto-propagate.
const _buildMenu = ( ) => {
menu . innerHTML = '' ;
for ( const opt of ls . options ) {
const row = document . createElement ( 'button' ) ;
row . type = 'button' ;
row . className = 'doc-langpicker-item' ;
row . dataset . value = opt . value ;
row . setAttribute ( 'role' , 'option' ) ;
const ic = opt . value
? langIcon ( opt . value , 14 , { style : 'opacity:0.85;' } )
// Empty value = the "type" placeholder option — small dot so the
// row still aligns with the others (and the picker shows _some_
// mark when no type is set yet).
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.5;"><circle cx="12" cy="12" r="3"/></svg>' ;
row . innerHTML = ic +
` <span class="doc-langpicker-label"> ${ uiModule . esc ( opt . textContent || opt . value ) } </span> ` ;
row . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( ls . value !== opt . value ) {
ls . value = opt . value ;
ls . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
}
_close ( ) ;
} ) ;
menu . appendChild ( row ) ;
}
} ;
_buildMenu ( ) ;
_syncLangPicker = ( ) => {
const v = ls . value || '' ;
const sel = Array . from ( ls . options ) . find ( o => o . value === v ) || ls . options [ 0 ] ;
const ic = v
? langIcon ( v , 14 , { style : 'opacity:0.85;flex-shrink:0;' } )
// No language picked yet → small dot mark so the trigger isn't bare.
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.5;flex-shrink:0;"><circle cx="12" cy="12" r="3"/></svg>' ;
trigger . innerHTML = ic +
` <span class="doc-langpicker-label"> ${ uiModule . esc ( sel ? . textContent || 'type' ) } </span> ` +
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;opacity:0.6;"><polyline points="6 9 12 15 18 9"/></svg>' ;
// Highlight the current row in the open menu.
menu . querySelectorAll ( '.doc-langpicker-item' ) . forEach ( r => {
r . classList . toggle ( 'is-selected' , r . dataset . value === v ) ;
} ) ;
} ;
const _close = ( ) => {
menu . style . display = 'none' ;
trigger . setAttribute ( 'aria-expanded' , 'false' ) ;
document . removeEventListener ( 'click' , _outsideClick , true ) ;
document . removeEventListener ( 'keydown' , _escKey , true ) ;
} ;
const _outsideClick = ( e ) => {
if ( ! menu . contains ( e . target ) && e . target !== trigger ) _close ( ) ;
} ;
const _escKey = ( e ) => {
if ( e . key !== 'Escape' || menu . style . display === 'none' ) return ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
e . stopImmediatePropagation ? . ( ) ;
_close ( ) ;
} ;
trigger . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const open = menu . style . display !== 'none' ;
if ( open ) { _close ( ) ; return ; }
// Position the menu under the trigger (fixed so it escapes any
// overflow-clipped ancestor like the footer).
const r = trigger . getBoundingClientRect ( ) ;
menu . style . display = 'block' ;
menu . style . position = 'fixed' ;
menu . style . left = r . left + 'px' ;
menu . style . top = ( r . bottom + 4 ) + 'px' ;
menu . style . minWidth = r . width + 'px' ;
// If it would overflow the bottom of the viewport, flip above.
requestAnimationFrame ( ( ) => {
const mr = menu . getBoundingClientRect ( ) ;
if ( mr . bottom > window . innerHeight - 8 ) {
menu . style . top = Math . max ( 8 , r . top - mr . height - 4 ) + 'px' ;
}
} ) ;
trigger . setAttribute ( 'aria-expanded' , 'true' ) ;
document . addEventListener ( 'click' , _outsideClick , true ) ;
document . addEventListener ( 'keydown' , _escKey , true ) ;
} ) ;
// Hide the native select but keep it in the layout for screen readers
// / programmatic value sets / focus management. The icon span next to
// it is removed since the trigger now carries the current icon.
ls . classList . add ( 'doc-langpicker-native-hidden' ) ;
const iconSpan = document . getElementById ( 'doc-language-icon' ) ;
if ( iconSpan ) iconSpan . remove ( ) ;
ls . parentNode . insertBefore ( trigger , ls ) ;
// Menu is body-mounted so position:fixed coords work cleanly.
document . body . appendChild ( menu ) ;
_syncLangPicker ( ) ;
} ) ( ) ;
document . getElementById ( 'doc-language-select' ) . addEventListener ( 'change' , ( ) => {
_syncLangIcon ( ) ;
_syncLangPicker ( ) ;
const val = document . getElementById ( 'doc-language-select' ) . value ;
// For form-backed docs, the select toggles between PDF view and the
// markdown source instead of changing the underlying language.
const live = document . getElementById ( 'doc-editor-textarea' ) ? . value
|| docs . get ( activeDocId ) ? . content || '' ;
if ( _isFormBackedDoc ( live ) && ( val === 'pdf' || val === 'markdown' ) ) {
_setPdfViewActive ( val === 'pdf' ) ;
return ;
}
// Mark user explicitly chose a language — stop auto-detection
if ( activeDocId && docs . has ( activeDocId ) ) {
docs . get ( activeDocId ) . userSetLanguage = ( val !== '' ) ;
}
updateLanguage ( ) ;
syncHighlighting ( ) ;
// Show/hide markdown toolbar
const lang = document . getElementById ( 'doc-language-select' ) . value ;
const mdToolbar = document . getElementById ( 'doc-md-toolbar' ) ;
if ( mdToolbar ) {
// Toolbar stays visible for every type now; only the items inside
// gate themselves on language.
mdToolbar . style . display = '' ;
if ( mdToolbar . _syncOverflow ) requestAnimationFrame ( mdToolbar . _syncOverflow ) ;
}
// If switching away from markdown, exit preview
if ( lang !== 'markdown' ) {
_setMarkdownPreviewActive ( false ) ;
}
// If switching away from CSV, exit table preview
if ( lang !== 'csv' ) {
const csvPreview = document . getElementById ( 'doc-csv-preview' ) ;
const wrap2 = document . getElementById ( 'doc-editor-wrap' ) ;
if ( csvPreview ) csvPreview . style . display = 'none' ;
if ( wrap2 ) wrap2 . style . display = '' ;
}
// If switching away from html, exit HTML preview
if ( ! _isRenderLang ( lang ) ) exitHtmlPreview ( ) ;
// Show/hide email fields
if ( lang === 'email' ) {
const doc = activeDocId && docs . get ( activeDocId ) ;
if ( doc ) _showEmailFields ( doc ) ;
} else {
_hideEmailFields ( ) ;
}
// Sync header action buttons for new language
_syncHeaderActions ( ) ;
} ) ;
// Email send/draft buttons
// Inject emoji picker button into markdown toolbar
const emojiSlot = document . getElementById ( 'md-toolbar-emoji-slot' ) ;
if ( emojiSlot && ! emojiSlot . querySelector ( '.emoji-picker-btn' ) ) {
// Resolve the live target on click: the WYSIWYG email contenteditable
// when active, otherwise the plain markdown textarea.
emojiSlot . appendChild ( emojiPicker . createEmojiButton (
( ) => _emailRichbodyActive ( ) || document . getElementById ( 'doc-editor-textarea' )
) ) ;
}
document . getElementById ( 'doc-email-send-btn' ) ? . addEventListener ( 'click' , ( ) => {
// Pressing Send must never leave the "more options" menu showing.
const _m = document . getElementById ( 'doc-email-more-menu' ) ;
if ( _m ) _m . style . display = 'none' ;
document . getElementById ( 'doc-email-send-caret' ) ? . setAttribute ( 'aria-expanded' , 'false' ) ;
_sendEmail ( ) ;
} ) ;
// Ctrl+Enter / Cmd+Enter sends the email when an email doc is active
// Bind once at module level via a guard to avoid duplicate listeners on re-open
if ( ! window . _emailCtrlEnterBound ) {
window . _emailCtrlEnterBound = true ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( ( e . ctrlKey || e . metaKey ) && e . key === 'Enter' ) {
const doc = activeDocId && docs . get ( activeDocId ) ;
if ( doc && doc . language === 'email' && isOpen ) {
e . preventDefault ( ) ;
_sendEmail ( ) ;
}
}
} ) ;
}
document . getElementById ( 'doc-email-draft-btn' ) ? . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'doc-email-more-menu' ) . style . display = 'none' ;
_saveDraft ( ) ;
} ) ;
document . getElementById ( 'doc-email-discard-btn' ) ? . addEventListener ( 'click' , _discardEmail ) ;
document . getElementById ( 'doc-email-unread-btn' ) ? . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'doc-email-more-menu' ) . style . display = 'none' ;
_markUnreadAndClose ( ) ;
} ) ;
document . getElementById ( 'doc-email-schedule-btn' ) ? . addEventListener ( 'click' , ( e ) => {
const anchor = document . getElementById ( 'doc-email-send-caret' ) || e . currentTarget ;
document . getElementById ( 'doc-email-more-menu' ) . style . display = 'none' ;
_scheduleSend ( anchor ) ;
} ) ;
document . getElementById ( 'doc-email-ai-reply-btn' ) ? . addEventListener ( 'click' , _aiReply ) ;
2026-06-02 22:38:55 +09:00
const collapseBtn = document . getElementById ( 'doc-email-collapse-btn' ) ;
if ( collapseBtn && ! collapseBtn . _emailCollapseWired ) {
collapseBtn . _emailCollapseWired = true ;
collapseBtn . addEventListener ( 'pointerdown' , ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
const focusState = _captureEmailBodyFocusState ( ) ;
const header = document . getElementById ( 'doc-email-header' ) ;
const nextCollapsed = ! header ? . classList . contains ( 'doc-email-header-collapsed' ) ;
_setEmailHeaderCollapsed ( nextCollapsed ) ;
if ( ! nextCollapsed ) _restoreEmailBodyFocusState ( focusState ) ;
} ) ;
collapseBtn . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
} ) ;
}
[ 'doc-email-to' , 'doc-email-cc' , 'doc-email-bcc' , 'doc-email-subject' ] . forEach ( id => {
document . getElementById ( id ) ? . addEventListener ( 'input' , _syncEmailHeaderSummary ) ;
document . getElementById ( id ) ? . addEventListener ( 'focus' , ( ) => _setEmailHeaderCollapsed ( false , { manual : false } ) ) ;
} ) ;
document . getElementById ( 'doc-email-richbody' ) ? . addEventListener ( 'focus' , _maybeAutoCollapseEmailHeader ) ;
if ( window . visualViewport && ! window . _docEmailViewportCollapseBound ) {
window . _docEmailViewportCollapseBound = true ;
window . visualViewport . addEventListener ( 'resize' , _maybeAutoCollapseEmailHeader ) ;
}
2026-05-31 23:58:26 +09:00
// Split-button caret toggles the send-options menu (drops up).
document . getElementById ( 'doc-email-send-caret' ) ? . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const menu = document . getElementById ( 'doc-email-more-menu' ) ;
const caret = document . getElementById ( 'doc-email-send-caret' ) ;
if ( ! menu ) return ;
const opening = menu . style . display === 'none' ;
menu . style . display = opening ? '' : 'none' ;
if ( caret ) caret . setAttribute ( 'aria-expanded' , String ( opening ) ) ;
} ) ;
document . addEventListener ( 'click' , ( e ) => {
const menu = document . getElementById ( 'doc-email-more-menu' ) ;
// Keep the menu open ONLY while interacting with the caret itself or the
// menu. Any other click — including the Send button (which sits in the
// same .email-send-split) — closes it, so the popup is tied to the arrow.
if ( menu && ! e . target . closest ( '#doc-email-send-caret, #doc-email-more-menu' ) ) {
menu . style . display = 'none' ;
document . getElementById ( 'doc-email-send-caret' ) ? . setAttribute ( 'aria-expanded' , 'false' ) ;
}
} ) ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key !== 'Escape' ) return ;
const menu = document . getElementById ( 'doc-email-more-menu' ) ;
if ( ! menu || menu . style . display === 'none' ) return ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
e . stopImmediatePropagation ? . ( ) ;
menu . style . display = 'none' ;
document . getElementById ( 'doc-email-send-caret' ) ? . setAttribute ( 'aria-expanded' , 'false' ) ;
} , true ) ;
// Attachments
document . getElementById ( 'doc-email-attach-btn' ) ? . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'doc-email-file-input' ) ? . click ( ) ;
} ) ;
document . getElementById ( 'md-toolbar-attach-btn' ) ? . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'doc-email-file-input' ) ? . click ( ) ;
} ) ;
document . getElementById ( 'doc-email-file-input' ) ? . addEventListener ( 'change' , _handleAttachUpload ) ;
// Cc/Bcc toggle
document . getElementById ( 'doc-email-show-cc' ) ? . addEventListener ( 'click' , ( ) => {
2026-06-02 22:38:55 +09:00
_setEmailHeaderCollapsed ( false , { manual : false } ) ;
2026-05-31 23:58:26 +09:00
const ccRow = document . getElementById ( 'doc-email-cc-row' ) ;
const bccRow = document . getElementById ( 'doc-email-bcc-row' ) ;
if ( ccRow ) ccRow . style . display = '' ;
if ( bccRow ) bccRow . style . display = '' ;
document . getElementById ( 'doc-email-show-cc' ) . style . display = 'none' ;
2026-06-02 22:38:55 +09:00
_syncEmailHeaderSummary ( ) ;
2026-05-31 23:58:26 +09:00
} ) ;
// Autocomplete for To / Cc / Bcc — typed fragment after the last
// comma triggers contact search; Enter / Tab / click on a suggestion
// appends "<email>, " so the user can keep typing more recipients.
_wireRecipientAutocomplete ( 'doc-email-to' , 'doc-email-to-suggestions' ) ;
_wireRecipientAutocomplete ( 'doc-email-cc' , 'doc-email-cc-suggestions' ) ;
_wireRecipientAutocomplete ( 'doc-email-bcc' , 'doc-email-bcc-suggestions' ) ;
// Header unified action button (preview or run depending on language)
document . getElementById ( 'doc-header-preview-btn' ) . addEventListener ( 'click' , ( ) => {
const lang = ( document . getElementById ( 'doc-language-select' ) ? . value || '' ) . toLowerCase ( ) ;
if ( lang === 'markdown' ) toggleMarkdownPreview ( ) ;
else if ( lang === 'csv' ) toggleCsvPreview ( ) ;
else if ( _isRenderLang ( lang ) ) toggleHtmlPreview ( ) ;
else {
// Runnable language — toggle output
const outputPanel = document . getElementById ( 'doc-run-output' ) ;
if ( outputPanel && outputPanel . style . display !== 'none' ) {
outputPanel . style . display = 'none' ;
} else {
runDocument ( ) ;
}
}
_syncHeaderActions ( ) ;
} ) ;
// Markdown Edit/Preview two-icon switch — click a side to go to that view.
document . getElementById ( 'doc-md-view-toggle' ) ? . addEventListener ( 'click' , ( e ) => {
const opt = e . target . closest ( '.md-view-opt' ) ;
if ( ! opt ) return ;
const wantPreview = opt . dataset . mdview === 'preview' ;
const mdPrev = document . getElementById ( 'doc-md-preview' ) ;
const isPreview = mdPrev && mdPrev . style . display !== 'none' ;
if ( wantPreview !== isPreview ) toggleMarkdownPreview ( ) ;
_syncHeaderActions ( ) ;
} ) ;
// Unified Code / Run-or-View two-icon switch — language-aware: CSV flips
// between code and the table view, Python/JS/etc. between code and run
// output, HTML/SVG/XML between code and the iframe preview.
document . getElementById ( 'doc-render-view-toggle' ) ? . addEventListener ( 'click' , ( e ) => {
const opt = e . target . closest ( '.md-view-opt' ) ;
if ( ! opt ) return ;
const wantRun = opt . dataset . renderview === 'run' ;
const lang = ( document . getElementById ( 'doc-language-select' ) ? . value || '' ) . toLowerCase ( ) ;
if ( lang === 'csv' ) {
const csv = document . getElementById ( 'doc-csv-preview' ) ;
const isOn = csv && csv . style . display !== 'none' ;
if ( wantRun !== isOn ) toggleCsvPreview ( ) ;
} else if ( _isRenderLang ( lang ) ) {
const htmlPrev = document . getElementById ( 'doc-html-preview' ) ;
const isOn = htmlPrev && htmlPrev . style . display !== 'none' ;
if ( wantRun !== isOn ) toggleHtmlPreview ( ) ;
} else {
// Runnable language (python / js / ts / bash …) — clicking Run is
// a one-shot execute; clicking Code dismisses the output pane.
if ( wantRun ) {
document . getElementById ( 'doc-header-preview-btn' ) ? . click ( ) ;
} else {
const out = document . getElementById ( 'doc-run-output' ) ;
if ( out ) out . style . display = 'none' ;
}
}
_syncHeaderActions ( ) ;
} ) ;
// Font size toggle (S → M → L)
const fontBtn = document . getElementById ( 'doc-fontsize-btn' ) ;
const editorWrap = document . getElementById ( 'doc-editor-wrap' ) ;
const _fontSizes = [ 's' , 'm' , 'l' ] ;
const _iconSizes = [ 12 , 14 , 16 ] ;
let _fontIdx = parseInt ( localStorage . getItem ( 'odysseus-doc-fontsize' ) || '0' , 10 ) ;
if ( ! ( _fontIdx >= 0 && _fontIdx < 3 ) ) _fontIdx = 0 ;
function _applyDocFont ( ) {
const richEmailBody = document . getElementById ( 'doc-email-richbody' ) ;
[ editorWrap , richEmailBody ] . filter ( Boolean ) . forEach ( el => {
el . classList . remove ( 'doc-font-s' , 'doc-font-m' , 'doc-font-l' ) ;
if ( _fontSizes [ _fontIdx ] !== 's' ) el . classList . add ( 'doc-font-' + _fontSizes [ _fontIdx ] ) ;
} ) ;
if ( fontBtn ) {
fontBtn . dataset . size = _fontSizes [ _fontIdx ] ;
// Keep the original behaviour: the icon itself grows with the size.
const svg = fontBtn . querySelector ( 'svg' ) ;
if ( svg ) { const sz = _iconSizes [ _fontIdx ] ; svg . setAttribute ( 'width' , sz ) ; svg . setAttribute ( 'height' , sz ) ; }
// Show only the active size letter (just S, or just M, or just L).
fontBtn . querySelectorAll ( '.doc-fontsize-levels [data-sz]' ) . forEach ( el => {
const active = el . dataset . sz === _fontSizes [ _fontIdx ] ;
el . classList . toggle ( 'active' , active ) ;
el . style . display = active ? '' : 'none' ;
} ) ;
}
localStorage . setItem ( 'odysseus-doc-fontsize' , _fontIdx ) ;
}
_applyDocFont ( ) ;
// Click cycles through the sizes (S → M → L → S).
if ( fontBtn ) fontBtn . addEventListener ( 'click' , ( ) => {
_fontIdx = ( _fontIdx + 1 ) % 3 ;
_applyDocFont ( ) ;
syncHighlighting ( ) ;
} ) ;
// Undo button in header
const docUndoBtn = document . getElementById ( 'doc-undo-btn' ) ;
if ( docUndoBtn ) docUndoBtn . addEventListener ( 'click' , async ( ) => {
const pdfPane = document . getElementById ( 'doc-pdf-view' ) ;
const pdfVisible = pdfPane && pdfPane . style . display !== 'none' ;
if ( pdfVisible && await _undoPdfPaneAction ( ) ) return ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ta ) {
ta . focus ( ) ; // execCommand('undo') needs the textarea focused
document . execCommand ( 'undo' ) ;
_dismissDocKb ( ) ; // then force the keyboard back down on touch
}
} ) ;
// Diff toggle button — compare current content against previous version
const diffToggleBtn = document . getElementById ( 'doc-diff-toggle-btn' ) ;
if ( diffToggleBtn ) diffToggleBtn . addEventListener ( 'click' , async ( ) => {
if ( _diffModeActive ) {
exitDiffMode ( true ) ;
return ;
}
if ( ! activeDocId ) return ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! ta ) return ;
const current = ta . value ;
// Fetch version history and compare against previous version
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /versions ` ) ;
if ( ! res . ok ) throw new Error ( 'Failed' ) ;
const versions = await res . json ( ) ;
if ( versions . length < 2 ) {
if ( uiModule ) uiModule . showToast ( 'No previous version to compare' ) ;
return ;
}
// versions are sorted desc — [0] is latest, [1] is previous
const prevContent = versions [ 1 ] . content || '' ;
if ( prevContent === current ) {
if ( uiModule ) uiModule . showToast ( 'No changes from previous version' ) ;
return ;
}
enterDiffMode ( prevContent , current ) ;
} catch {
if ( uiModule ) uiModule . showError ( 'Failed to load version history' ) ;
}
} ) ;
// Export PDF (form-backed markdown docs)
document . getElementById ( 'doc-export-pdf-btn' ) ? . addEventListener ( 'click' , _downloadFilledPdf ) ;
// Toggle inline PDF view (form-backed markdown docs). Default for a
// form-backed doc is "active" — the toggle reads back the visible state.
document . getElementById ( 'doc-pdf-view-btn' ) ? . addEventListener ( 'click' , ( ) => {
const pane = document . getElementById ( 'doc-pdf-view' ) ;
const visible = pane && pane . style . display !== 'none' ;
_setPdfViewActive ( ! visible ) ;
} ) ;
// Toolbar buttons toggle: clicking the active mode clears it. Otherwise
// the mode stays armed across multiple placements until the user turns
// it off explicitly.
document . getElementById ( 'doc-pdf-add-text-btn' ) ? . addEventListener ( 'click' , ( ) => _setPdfDropMode ( _pdfDropMode === 'text' ? null : 'text' ) ) ;
document . getElementById ( 'doc-pdf-add-check-btn' ) ? . addEventListener ( 'click' , ( ) => _setPdfDropMode ( _pdfDropMode === 'check' ? null : 'check' ) ) ;
document . getElementById ( 'doc-pdf-add-sign-btn' ) ? . addEventListener ( 'click' , ( ) => _setPdfDropMode ( _pdfDropMode === 'signature' ? null : 'signature' ) ) ;
document . getElementById ( 'doc-pdf-refresh-btn' ) ? . addEventListener ( 'click' , ( ) => _renderPdfPane ( ) ) ;
// Markdown formatting toolbar
initMdToolbar ( ) ;
// Wire highlighting sync
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
const pre = document . getElementById ( 'doc-editor-highlight' ) ;
if ( ta && pre ) {
ta . addEventListener ( 'input' , ( ) => {
// Typing invalidates any pinned selection highlight
if ( _selections . length ) clearSelection ( ) ;
// Auto-create a document if user types/pastes with no active doc.
// Skip while a createDocument POST is in flight — otherwise typing
// during the round-trip spawns a duplicate untitled doc.
if ( ! activeDocId && ! _creatingDoc && ta . value . trim ( ) ) {
_autoCreateFromInput ( ta . value ) ;
return ;
}
// Sync text content immediately (prevents visual duplication from scroll desync)
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( codeEl && ! codeEl . dataset . hasDiff ) {
codeEl . textContent = ta . value + '\n' ;
codeEl . style . minHeight = ta . scrollHeight + 'px' ;
}
if ( pre ) {
pre . scrollTop = ta . scrollTop ;
pre . scrollLeft = ta . scrollLeft ;
}
updateLineNumbers ( ta . value ) ;
// Debounce expensive operations (syntax highlighting, auto-detect, auto-save)
clearTimeout ( _hlDebounce ) ;
_hlDebounce = setTimeout ( syncHighlighting , 80 ) ;
clearTimeout ( _autoDetectDebounce ) ;
_autoDetectDebounce = setTimeout ( attemptAutoDetect , AUTO _DETECT _DELAY ) ;
clearTimeout ( _autoTitleDebounce ) ;
_autoTitleDebounce = setTimeout ( ( ) => autoTitleFromContent ( ta . value ) , 600 ) ;
clearTimeout ( _autoSaveDebounce ) ;
_autoSaveDebounce = setTimeout ( ( ) => { saveDocument ( { silent : true } ) ; } , 2000 ) ;
} ) ;
ta . addEventListener ( 'scroll' , ( ) => {
const code = document . getElementById ( 'doc-editor-code' ) ;
if ( code ) code . style . minHeight = ta . scrollHeight + 'px' ;
pre . scrollTop = ta . scrollTop ;
pre . scrollLeft = ta . scrollLeft ;
syncGutterScroll ( ) ;
syncSelectionOverlay ( ) ;
// Re-position find rects so they track the textarea on scroll.
if ( _findMatches && _findMatches . length ) {
const _q = document . getElementById ( 'doc-find-input' ) ? . value || '' ;
if ( _q ) renderFindRects ( _findMatches . map ( s => [ s , s + _q . length ] ) , _findIdx ) ;
}
} ) ;
// Tab key inserts a real tab; Escape clears selection
ta . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' ) {
if ( _diffModeActive ) { exitDiffMode ( true ) ; return ; }
// First Esc clears any pinned selection without closing the
// panel. Second Esc (no selection left) minimises the panel
// to a dock chip. The previous all-in-one path made one Esc
// press both clear and close, which was annoying because the
// user lost their working doc by hitting Esc to dismiss a
// mistaken highlight.
if ( _selections . length > 0 ) {
clearSelection ( ) ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
return ;
}
// No pinned selection — Esc MINIMIZES the panel (tabs it
// down to a dock chip) — same as the chevron button.
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
closePanel ( 'down' ) ;
return ;
}
if ( e . key === 'Tab' ) {
e . preventDefault ( ) ;
document . execCommand ( 'insertText' , false , '\t' ) ;
}
// Markdown shortcuts (only when language is markdown)
const lang = document . getElementById ( 'doc-language-select' ) ? . value ;
if ( lang === 'markdown' && ( e . ctrlKey || e . metaKey ) ) {
if ( e . key === 'b' ) { e . preventDefault ( ) ; applyMdFormat ( 'bold' ) ; }
else if ( e . key === 'i' ) { e . preventDefault ( ) ; applyMdFormat ( 'italic' ) ; }
else if ( e . key === 'k' ) { e . preventDefault ( ) ; applyMdFormat ( 'link' ) ; }
}
} ) ;
// ── In-document find (Ctrl+F) ──
let _findMatches = [ ] ;
let _findIdx = - 1 ;
function _openFindBar ( ) {
const bar = document . getElementById ( 'doc-find-bar' ) ;
if ( ! bar ) return ;
bar . style . display = 'flex' ;
// The highlight overlay is normally display:none (single-layer
// rendering — textarea owns the visible text). Find marks live
// inside that overlay, so we have to re-show it while find is
// active. The body class lets a CSS rule un-hide it without
// touching every per-language stylesheet path.
document . body . classList . add ( 'doc-find-active' ) ;
const inp = document . getElementById ( 'doc-find-input' ) ;
if ( inp ) { inp . focus ( ) ; inp . select ( ) ; }
}
function _closeFindBar ( ) {
const bar = document . getElementById ( 'doc-find-bar' ) ;
if ( bar ) bar . style . display = 'none' ;
document . body . classList . remove ( 'doc-find-active' ) ;
_findMatches = [ ] ;
_findIdx = - 1 ;
const cnt = document . getElementById ( 'doc-find-count' ) ;
if ( cnt ) cnt . textContent = '' ;
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( codeEl ) {
delete codeEl . dataset . findQuery ;
delete codeEl . dataset . findCurrent ;
applyFindMarks ( codeEl ) ;
}
renderFindRects ( [ ] , - 1 ) ;
ta . focus ( ) ;
}
function _doFind ( dir , focusTextarea ) {
const inp = document . getElementById ( 'doc-find-input' ) ;
const cnt = document . getElementById ( 'doc-find-count' ) ;
if ( ! inp ) return ;
const q = inp . value ;
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( ! q ) {
_findMatches = [ ] ; _findIdx = - 1 ;
if ( cnt ) cnt . textContent = '' ;
if ( codeEl ) { delete codeEl . dataset . findQuery ; delete codeEl . dataset . findCurrent ; applyFindMarks ( codeEl ) ; }
return ;
}
const text = ta . value ;
const lq = q . toLowerCase ( ) ;
const lt = text . toLowerCase ( ) ;
_findMatches = [ ] ;
let pos = 0 ;
while ( true ) {
const i = lt . indexOf ( lq , pos ) ;
if ( i < 0 ) break ;
_findMatches . push ( i ) ;
pos = i + 1 ;
}
if ( _findMatches . length === 0 ) {
_findIdx = - 1 ;
if ( cnt ) cnt . textContent = '0 results' ;
if ( codeEl ) { codeEl . dataset . findQuery = q ; delete codeEl . dataset . findCurrent ; applyFindMarks ( codeEl ) ; }
renderFindRects ( [ ] , - 1 ) ;
return ;
}
if ( dir === 'next' ) {
_findIdx = _findIdx < _findMatches . length - 1 ? _findIdx + 1 : 0 ;
} else if ( dir === 'prev' ) {
_findIdx = _findIdx > 0 ? _findIdx - 1 : _findMatches . length - 1 ;
} else {
_findIdx = 0 ;
}
if ( cnt ) cnt . textContent = ` ${ _findIdx + 1 } / ${ _findMatches . length } ` ;
const matchPos = _findMatches [ _findIdx ] ;
// Highlight the match in the textarea without stealing focus from the input
ta . setSelectionRange ( matchPos , matchPos + q . length ) ;
const linesBefore = text . slice ( 0 , matchPos ) . split ( '\n' ) . length ;
const lineH = parseFloat ( getComputedStyle ( ta ) . lineHeight ) || 18 ;
ta . scrollTop = Math . max ( 0 , ( linesBefore - 3 ) * lineH ) ;
if ( codeEl ) {
codeEl . dataset . findQuery = q ;
codeEl . dataset . findCurrent = String ( _findIdx ) ;
applyFindMarks ( codeEl ) ;
}
// Dedicated overlay rects on top of the textarea — bulletproof
// visibility across markdown / email / code modes.
renderFindRects ( _findMatches . map ( s => [ s , s + q . length ] ) , _findIdx ) ;
if ( focusTextarea ) ta . focus ( ) ;
}
document . getElementById ( 'doc-find-close' ) ? . addEventListener ( 'click' , _closeFindBar ) ;
document . getElementById ( 'doc-find-next' ) ? . addEventListener ( 'click' , ( ) => _doFind ( 'next' , true ) ) ;
document . getElementById ( 'doc-find-prev' ) ? . addEventListener ( 'click' , ( ) => _doFind ( 'prev' , true ) ) ;
document . getElementById ( 'doc-find-input' ) ? . addEventListener ( 'input' , ( ) => _doFind ( 'first' , false ) ) ;
document . getElementById ( 'doc-find-input' ) ? . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' ) { e . preventDefault ( ) ; _closeFindBar ( ) ; }
else if ( e . key === 'Enter' ) { e . preventDefault ( ) ; _doFind ( e . shiftKey ? 'prev' : 'next' , false ) ; }
} ) ;
// Intercept Ctrl+F on the editor pane
pane . addEventListener ( 'keydown' , ( e ) => {
if ( ( e . ctrlKey || e . metaKey ) && e . key === 'f' ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
_openFindBar ( ) ;
}
} ) ;
// Delete (or Backspace) over the doc PANEL itself (not while typing in
// a field) deletes the active document. Matches the email-reader Delete
// behavior so the keyboard shortcut is consistent across surfaces.
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key !== 'Delete' && e . key !== 'Backspace' ) return ;
if ( ! isPanelOpen ( ) ) return ;
const t = e . target ;
if ( t && ( t . tagName === 'INPUT' || t . tagName === 'TEXTAREA' || t . tagName === 'SELECT' || t . isContentEditable ) ) return ;
e . preventDefault ( ) ;
deleteActiveDocument ( ) ;
} ) ;
// Drag-and-drop file attachment for email compose docs. The whole pane
// is the drop target; visual highlight while a drag is hovering. Files
// dropped get uploaded via the same compose-upload endpoint as the
// file picker.
let _dragDepth = 0 ;
const _isEmailDrag = ( e ) => {
const doc = docs . get ( activeDocId ) ;
if ( ! doc || doc . language !== 'email' ) return false ;
const dt = e . dataTransfer ;
if ( ! dt ) return false ;
// Files-only — don't trigger on text drags etc.
return dt . types && Array . from ( dt . types ) . includes ( 'Files' ) ;
} ;
pane . addEventListener ( 'dragenter' , ( e ) => {
if ( ! _isEmailDrag ( e ) ) return ;
e . preventDefault ( ) ;
_dragDepth ++ ;
pane . classList . add ( 'email-dragover' ) ;
} ) ;
pane . addEventListener ( 'dragover' , ( e ) => {
if ( ! _isEmailDrag ( e ) ) return ;
e . preventDefault ( ) ;
e . dataTransfer . dropEffect = 'copy' ;
} ) ;
pane . addEventListener ( 'dragleave' , ( e ) => {
if ( ! _isEmailDrag ( e ) ) return ;
_dragDepth = Math . max ( 0 , _dragDepth - 1 ) ;
if ( _dragDepth === 0 ) pane . classList . remove ( 'email-dragover' ) ;
} ) ;
pane . addEventListener ( 'drop' , async ( e ) => {
if ( ! _isEmailDrag ( e ) ) return ;
e . preventDefault ( ) ;
_dragDepth = 0 ;
pane . classList . remove ( 'email-dragover' ) ;
const files = e . dataTransfer . files ;
if ( files && files . length ) await _uploadComposeFiles ( files ) ;
} ) ;
// Track selection for AI-assisted editing
ta . addEventListener ( 'mouseup' , ( ) => {
setTimeout ( updateSelectionState , 50 ) ;
} ) ;
ta . addEventListener ( 'keyup' , ( e ) => {
if ( e . shiftKey ) updateSelectionState ( ) ;
} ) ;
// ESC clears any pinned selections — matches the badge's clear
// button so users have a keyboard shortcut for the same action.
ta . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' && _selections . length > 0 ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
clearSelection ( ) ;
}
} ) ;
}
renderTabs ( ) ;
// If no docs loaded, show empty state with helpful placeholder
if ( docs . size === 0 || ! activeDocId ) {
showEmptyState ( ) ;
}
}
/** Apply markdown formatting to the textarea selection */
let _lastMdFormat = { action : null , t : 0 } ;
// Styled two-field link dialog (display text + URL). Resolves {url, text}
// or null on cancel. Reuses the styled-prompt CSS. Text is optional — left
// empty it falls back to the selected text, then the URL itself.
function _promptLink ( defaultText = '' ) {
return new Promise ( resolve => {
const overlay = document . createElement ( 'div' ) ;
overlay . id = 'doc-link-prompt-overlay' ;
overlay . className = 'modal' ;
overlay . innerHTML =
'<div class="modal-content styled-confirm-box styled-prompt-box">' +
'<div class="modal-header"><h4>Insert link</h4></div>' +
'<div class="modal-body">' +
'<input type="text" id="doc-link-text" class="styled-prompt-input" placeholder="Link text (optional)" maxlength="500" />' +
'<input type="url" id="doc-link-url" class="styled-prompt-input" placeholder="https://example.com" maxlength="2048" style="margin-top:8px;" />' +
'</div>' +
'<div class="modal-footer">' +
'<button id="doc-link-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button>' +
'<button id="doc-link-ok" class="confirm-btn confirm-btn-primary">Insert</button>' +
'</div>' +
'</div>' ;
document . body . appendChild ( overlay ) ;
const textEl = overlay . querySelector ( '#doc-link-text' ) ;
const urlEl = overlay . querySelector ( '#doc-link-url' ) ;
textEl . value = defaultText || '' ;
function done ( result ) {
overlay . remove ( ) ;
document . removeEventListener ( 'keydown' , onKey , true ) ;
resolve ( result ) ;
}
function submit ( ) {
const url = ( urlEl . value || '' ) . trim ( ) ;
if ( ! url ) { urlEl . focus ( ) ; return ; }
done ( { url , text : ( textEl . value || '' ) . trim ( ) } ) ;
}
function onKey ( e ) {
if ( e . key === 'Escape' ) { e . preventDefault ( ) ; e . stopPropagation ( ) ; done ( null ) ; }
}
overlay . querySelector ( '#doc-link-ok' ) . addEventListener ( 'click' , submit ) ;
overlay . querySelector ( '#doc-link-cancel' ) . addEventListener ( 'click' , ( ) => done ( null ) ) ;
overlay . addEventListener ( 'click' , ( e ) => { if ( e . target === overlay ) done ( null ) ; } ) ;
urlEl . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; submit ( ) ; } } ) ;
textEl . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; urlEl . focus ( ) ; } } ) ;
document . addEventListener ( 'keydown' , onKey , true ) ;
// Focus the URL field when the text is prefilled; otherwise start at text.
requestAnimationFrame ( ( ) => { ( defaultText ? urlEl : textEl ) . focus ( ) ; } ) ;
} ) ;
}
// Email WYSIWYG link insertion. We snapshot the Range first (the dialog steals
// focus and would otherwise collapse it) and insert via direct DOM ops, since
// execCommand is unreliable once focus has moved to the modal.
async function _wysiwygInsertLink ( rich ) {
const selObj = window . getSelection ( ) ;
let savedRange = null ;
if ( selObj && selObj . rangeCount ) {
const r = selObj . getRangeAt ( 0 ) ;
if ( rich . contains ( r . commonAncestorContainer ) ) savedRange = r . cloneRange ( ) ;
}
const selText = savedRange ? savedRange . toString ( ) : '' ;
let res ;
try { res = await _promptLink ( selText ) ; } catch ( _ ) { res = null ; }
if ( ! res ) { rich . focus ( ) ; return ; }
let url = ( res . url || '' ) . trim ( ) ;
if ( ! url ) { rich . focus ( ) ; return ; }
if ( ! /^[a-z][a-z0-9+.-]*:/i . test ( url ) && ! url . startsWith ( '//' ) ) url = 'https://' + url ;
const linkText = ( res . text || '' ) . trim ( ) || selText || url ;
if ( ! savedRange ) {
savedRange = document . createRange ( ) ;
savedRange . selectNodeContents ( rich ) ;
savedRange . collapse ( false ) ;
}
const a = document . createElement ( 'a' ) ;
a . href = url ;
if ( selText && linkText === selText ) {
// Unchanged selection — wrap it to keep any inline formatting.
a . appendChild ( savedRange . extractContents ( ) ) ;
} else {
savedRange . deleteContents ( ) ;
a . textContent = linkText ;
}
savedRange . insertNode ( a ) ;
// Place the caret right after the inserted link.
const after = document . createRange ( ) ;
after . setStartAfter ( a ) ;
after . collapse ( true ) ;
rich . focus ( ) ;
const s = window . getSelection ( ) ;
s . removeAllRanges ( ) ;
s . addRange ( after ) ;
_syncEmailRichbody ( rich ) ;
}
function applyMdFormat ( action ) {
// Guard against a duplicate/"ghost" click firing the same toggle twice in
// quick succession — that would wrap then immediately unwrap, so the
// markers appear for a split second and vanish.
const _now = Date . now ( ) ;
if ( _lastMdFormat . action === action && _now - _lastMdFormat . t < 350 ) return ;
_lastMdFormat = { action , t : _now } ;
// Email WYSIWYG: format the live rich text via execCommand instead of
// inserting markdown markers into the (hidden) source textarea.
const _rich = _emailRichbodyActive ( ) ;
if ( _rich ) {
_rich . focus ( ) ;
// Link needs an async styled URL prompt — handle it separately so we can
// save/restore the selection (opening the modal collapses it otherwise).
if ( action === 'link' ) { _wysiwygInsertLink ( _rich ) ; return ; }
const _cmd = { bold : 'bold' , italic : 'italic' , strike : 'strikeThrough' ,
ul : 'insertUnorderedList' , ol : 'insertOrderedList' , hr : 'insertHorizontalRule' } ;
try {
if ( _cmd [ action ] ) document . execCommand ( _cmd [ action ] ) ;
else if ( action === 'h1' || action === 'h2' || action === 'h3' ) {
// Toggle: if the block is already this heading, revert to a normal
// paragraph; otherwise apply (or switch to) the heading.
const cur = _currentBlockTag ( _rich ) ;
document . execCommand ( 'formatBlock' , false , ( cur === action ) ? 'div' : action ) ;
} else if ( action === 'code' ) {
const cur = _currentBlockTag ( _rich ) ;
document . execCommand ( 'formatBlock' , false , ( cur === 'pre' ) ? 'div' : 'pre' ) ;
}
// quote/check/codeblock have no clean execCommand — skipped in WYSIWYG v1.
} catch ( _ ) { }
_syncEmailRichbody ( _rich ) ;
if ( _rich . _syncActive ) _rich . _syncActive ( ) ;
return ;
}
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! ta ) return ;
const start = ta . selectionStart ;
const end = ta . selectionEnd ;
const val = ta . value ;
const sel = val . substring ( start , end ) ;
const before = val . substring ( 0 , start ) ;
const after = val . substring ( end ) ;
// Inline wrap toggles: bold, italic, strike, code
const wrapMarks = { bold : '**' , italic : '*' , strike : '~~' , code : '`' } ;
if ( wrapMarks [ action ] ) {
const m = wrapMarks [ action ] ;
_applyWrapToggle ( ta , before , sel , after , start , end , m , action ) ;
return ;
}
// Numbered list — special handling for incrementing numbers
if ( action === 'ol' ) {
_applyOrderedList ( ta , start , end ) ;
return ;
}
// Headings get their own toggle so applying the same level removes it and
// a different level switches cleanly (rather than stacking # markers).
if ( action === 'h1' || action === 'h2' || action === 'h3' ) {
_applyHeadingToggle ( ta , start , { h1 : '# ' , h2 : '## ' , h3 : '### ' } [ action ] ) ;
return ;
}
// Line prefix toggles: quote, lists, checkbox
const prefixMap = { quote : '> ' , ul : '- ' , check : '- [ ] ' } ;
if ( prefixMap [ action ] ) {
_applyLinePrefixToggle ( ta , start , end , prefixMap [ action ] ) ;
return ;
}
// Non-toggle actions
let insert = '' ;
let sS = start , sE = start ;
switch ( action ) {
case 'link' :
if ( sel ) {
insert = ` [ ${ sel } ](url) ` ;
sS = start + 1 ; sE = start + 1 + sel . length ;
} else {
insert = '[text](url)' ;
sS = start + 1 ; sE = start + 5 ;
}
break ;
case 'codeblock' : {
// Toggle: find if current line/selection is inside a ``` block
const linesBefore = val . substring ( 0 , start ) . split ( '\n' ) ;
const linesAfter = val . substring ( end ) . split ( '\n' ) ;
// Look backward for opening ```
let openIdx = - 1 ;
for ( let i = linesBefore . length - 1 ; i >= 0 ; i -- ) {
if ( /^```/ . test ( linesBefore [ i ] . trimEnd ( ) ) ) { openIdx = i ; break ; }
}
// Look forward for closing ```
let closeIdx = - 1 ;
for ( let i = 0 ; i < linesAfter . length ; i ++ ) {
if ( /^```\s*$/ . test ( linesAfter [ i ] . trimEnd ( ) ) ) { closeIdx = i ; break ; }
}
if ( openIdx >= 0 && closeIdx >= 0 ) {
// Unwrap: remove the opening and closing fence lines
const openLineStart = linesBefore . slice ( 0 , openIdx ) . join ( '\n' ) . length + ( openIdx > 0 ? 1 : 0 ) ;
const openLineEnd = openLineStart + linesBefore [ openIdx ] . length + 1 ; // +1 for \n
const closeLineStart = end + linesAfter . slice ( 0 , closeIdx ) . join ( '\n' ) . length + ( closeIdx > 0 ? 1 : 0 ) ;
const closeLineEnd = closeLineStart + linesAfter [ closeIdx ] . length + ( closeIdx < linesAfter . length - 1 ? 1 : 0 ) ;
// Remove closing first (so indices stay valid), then opening
_replaceRange ( ta , closeLineStart , closeLineEnd , '' ) ;
_replaceRange ( ta , openLineStart , openLineEnd , '' ) ;
const inner = val . substring ( openLineEnd , closeLineStart ) ;
ta . selectionStart = openLineStart ;
ta . selectionEnd = openLineStart + inner . length ;
return ;
}
// Wrap in code block
const nl = before . length > 0 && ! before . endsWith ( '\n' ) ? '\n' : '' ;
insert = nl + '```\n' + ( sel || '' ) + '\n```\n' ;
sS = start + nl . length + 4 ;
sE = sS + ( sel ? sel . length : 0 ) ;
break ;
}
case 'hr' : {
const nl = before . length > 0 && ! before . endsWith ( '\n' ) ? '\n' : '' ;
insert = ` ${ nl } --- \n ` ;
sE = sS = start + insert . length ;
break ;
}
default : return ;
}
_replaceRange ( ta , start , end , insert ) ;
ta . selectionStart = sS ;
ta . selectionEnd = sE ;
}
/** Replace a range in the textarea using execCommand to preserve undo stack */
function _replaceRange ( ta , from , to , text ) {
ta . focus ( ) ;
ta . selectionStart = from ;
ta . selectionEnd = to ;
const before = ta . value ;
let ok = false ;
try { ok = document . execCommand ( 'insertText' , false , text ) ; } catch ( _ ) { ok = false ; }
// execCommand('insertText') keeps native undo working. It silently no-ops on
// some mobile browsers though — so ONLY when it changed nothing do we splice
// the value directly (using the pre-edit value + original range, so we never
// double-insert). execCommand fires its own input event; the splice path
// dispatches one manually.
if ( ! ok && ta . value === before ) {
ta . value = before . slice ( 0 , from ) + text + before . slice ( to ) ;
ta . selectionStart = ta . selectionEnd = from + text . length ;
ta . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
}
}
/** Toggle inline wrap markers (**, *, ~~, `) */
function _applyWrapToggle ( ta , before , sel , after , start , end , mark , action ) {
const mLen = mark . length ;
// Case 1: selection is wrapped inside — e.g. selected "**bold**" → unwrap to "bold"
if ( sel . startsWith ( mark ) && sel . endsWith ( mark ) && sel . length > mLen * 2 ) {
const inner = sel . slice ( mLen , - mLen ) ;
_replaceRange ( ta , start , end , inner ) ;
ta . selectionStart = start ;
ta . selectionEnd = start + inner . length ;
return ;
}
// Case 2: markers are outside selection — e.g. **|bold|** → unwrap
if ( before . endsWith ( mark ) && after . startsWith ( mark ) ) {
_replaceRange ( ta , start - mLen , end + mLen , sel ) ;
ta . selectionStart = start - mLen ;
ta . selectionEnd = end - mLen ;
return ;
}
// Case 3: wrap — add markers. With no selection, insert empty markers and
// drop the cursor between them (don't inject the action name as text).
const inner = sel ;
const wrapped = mark + inner + mark ;
_replaceRange ( ta , start , end , wrapped ) ;
ta . selectionStart = start + mLen ;
ta . selectionEnd = start + mLen + inner . length ;
}
/** Toggle line prefix (headings, quotes, lists) */
// The block-level tag (h1/h2/h3/pre/p/…) containing the current selection in
// a contenteditable root — used to decide whether a heading toggle should
// apply or revert.
function _currentBlockTag ( root ) {
const sel = window . getSelection ( ) ;
if ( ! sel || ! sel . rangeCount ) return '' ;
let node = sel . getRangeAt ( 0 ) . startContainer ;
if ( node . nodeType === 3 ) node = node . parentNode ;
while ( node && node !== root ) {
const tag = node . tagName && node . tagName . toLowerCase ( ) ;
if ( tag && /^(h1|h2|h3|h4|h5|h6|p|div|pre|blockquote|li)$/ . test ( tag ) ) return tag ;
node = node . parentNode ;
}
return '' ;
}
// Heading toggle for the markdown textarea: strips any existing leading
// `#{1,6} `, then removes it (toggle off) if it was the same level, or applies
// the new level otherwise.
function _applyHeadingToggle ( ta , caret , prefix ) {
const val = ta . value ;
const lineStart = val . lastIndexOf ( '\n' , caret - 1 ) + 1 ;
const nlIdx = val . indexOf ( '\n' , caret ) ;
const lineEnd = nlIdx === - 1 ? val . length : nlIdx ;
const line = val . substring ( lineStart , lineEnd ) ;
const m = line . match ( /^(#{1,6}) / ) ;
let newLine ;
if ( m && m [ 1 ] . length === prefix . trim ( ) . length ) {
newLine = line . slice ( m [ 0 ] . length ) ; // same level → toggle off
} else if ( m ) {
newLine = prefix + line . slice ( m [ 0 ] . length ) ; // different level → switch
} else {
newLine = prefix + line ; // none → add
}
_replaceRange ( ta , lineStart , lineEnd , newLine ) ;
const delta = newLine . length - line . length ;
const pos = Math . max ( lineStart , caret + delta ) ;
ta . selectionStart = ta . selectionEnd = pos ;
ta . focus ( ) ;
}
function _applyLinePrefixToggle ( ta , start , end , prefix ) {
const val = ta . value ;
const sel = val . substring ( start , end ) ;
const lineStart = val . lastIndexOf ( '\n' , start - 1 ) + 1 ;
if ( sel ) {
// Multi-line: toggle prefix on each line
const lines = sel . split ( '\n' ) ;
const nonEmpty = lines . filter ( l => l . trim ( ) ) ;
const allPrefixed = nonEmpty . length > 0 && nonEmpty . every ( l => l . startsWith ( prefix ) ) ;
const result = allPrefixed
? lines . map ( l => l . startsWith ( prefix ) ? l . slice ( prefix . length ) : l ) . join ( '\n' )
: lines . map ( l => l . trim ( ) ? prefix + l : l ) . join ( '\n' ) ;
_replaceRange ( ta , start , end , result ) ;
ta . selectionStart = start ;
ta . selectionEnd = start + result . length ;
} else {
// No selection: toggle prefix on the current line
const lineBefore = val . substring ( lineStart , start ) ;
if ( lineBefore . startsWith ( prefix ) ) {
// Remove prefix
_replaceRange ( ta , lineStart , lineStart + prefix . length , '' ) ;
} else {
// Add prefix at line start
_replaceRange ( ta , lineStart , lineStart , prefix ) ;
}
}
}
/** Toggle ordered list with incrementing numbers */
function _applyOrderedList ( ta , start , end ) {
const val = ta . value ;
const sel = val . substring ( start , end ) ;
const lineStart = val . lastIndexOf ( '\n' , start - 1 ) + 1 ;
if ( sel ) {
const lines = sel . split ( '\n' ) ;
const nonEmpty = lines . filter ( l => l . trim ( ) ) ;
const allNumbered = nonEmpty . length > 0 && nonEmpty . every ( l => / ^ \ d + \ . \ s / . test ( l ) ) ;
const result = allNumbered
? lines . map ( l => l . replace ( /^\d+\.\s/ , '' ) ) . join ( '\n' )
: ( ( ) => { let n = 0 ; return lines . map ( l => l . trim ( ) ? ` ${ ++ n } . ${ l } ` : l ) . join ( '\n' ) ; } ) ( ) ;
_replaceRange ( ta , start , end , result ) ;
ta . selectionStart = start ;
ta . selectionEnd = start + result . length ;
} else {
const lineBefore = val . substring ( lineStart , start ) ;
if ( /^\d+\.\s/ . test ( lineBefore ) ) {
const prefixLen = lineBefore . match ( /^\d+\.\s/ ) [ 0 ] . length ;
_replaceRange ( ta , lineStart , lineStart + prefixLen , '' ) ;
} else {
// Find the previous numbered line to continue the sequence
const prevText = val . substring ( 0 , lineStart ) ;
const prevMatch = prevText . match ( /(\d+)\.\s[^\n]*\n$/ ) ;
const num = prevMatch ? parseInt ( prevMatch [ 1 ] ) + 1 : 1 ;
_replaceRange ( ta , lineStart , lineStart , ` ${ num } . ` ) ;
}
}
}
/** Wire up the markdown formatting toolbar */
// Grouped formatting dropdown (headings / code / lists). Menu is appended to
// <body> so the draggable panel's transform can't clip its fixed position.
let _mdDdOpenedAt = 0 ;
function _showMdDropdown ( toggleBtn ) {
const kind = toggleBtn . dataset . dd ;
const now = Date . now ( ) ;
const existing = document . getElementById ( 'doc-md-dd-menu' ) ;
// Mobile fires a duplicate/ghost click right after the real one. If it lands
// on the same toggle it would re-toggle the menu shut the instant it opened.
// Ignore a same-kind re-invocation within 400ms so the menu stays up.
if ( existing && existing . dataset . dd === kind && ( now - _mdDdOpenedAt ) < 400 ) return ;
const prevKind = existing && existing . dataset . dd ;
if ( existing ) existing . remove ( ) ;
if ( existing && prevKind === kind ) return ; // same toggle clicked → just close
_mdDdOpenedAt = now ;
const groups = {
heading : [ [ 'h1' , 'Heading 1' , 'H1' ] , [ 'h2' , 'Heading 2' , 'H2' ] , [ 'h3' , 'Heading 3' , 'H3' ] ] ,
code : [ [ 'code' , 'Inline code' , '`' ] , [ 'codeblock' , 'Code block' , '```' ] ] ,
list : [ [ 'ul' , 'Bullet list' , '•' ] , [ 'ol' , 'Numbered list' , '1.' ] ] ,
} ;
const items = groups [ kind ] ;
if ( ! items ) return ;
const rect = toggleBtn . getBoundingClientRect ( ) ;
const menu = document . createElement ( 'div' ) ;
menu . id = 'doc-md-dd-menu' ;
menu . dataset . dd = kind ;
menu . className = 'doc-overflow-menu open' ;
menu . style . position = 'fixed' ;
menu . style . top = ( rect . bottom + 4 ) + 'px' ;
menu . style . left = rect . left + 'px' ;
menu . style . zIndex = '9999' ;
items . forEach ( ( [ md , label , ico ] ) => {
const it = document . createElement ( 'button' ) ;
it . className = 'doc-overflow-item' ;
const icoSpan = document . createElement ( 'span' ) ;
icoSpan . className = 'md-dd-ico' ;
icoSpan . textContent = ico ;
const lbl = document . createElement ( 'span' ) ;
lbl . textContent = label ;
it . append ( icoSpan , lbl ) ;
// Don't let the menu item steal focus from the editor (preserve selection).
it . addEventListener ( 'mousedown' , ( ev ) => ev . preventDefault ( ) ) ;
it . addEventListener ( 'click' , ( ev ) => { ev . stopPropagation ( ) ; menu . remove ( ) ; applyMdFormat ( md ) ; } ) ;
menu . appendChild ( it ) ;
} ) ;
document . body . appendChild ( menu ) ;
const close = ( ev ) => {
if ( ev && ev . type === 'keydown' ) {
if ( ev . key !== 'Escape' ) return ;
ev . preventDefault ( ) ;
ev . stopPropagation ( ) ;
ev . stopImmediatePropagation ? . ( ) ;
}
if ( ev && ev . type === 'click' ) {
// Ignore the ghost/duplicate click mobile fires right after opening.
if ( Date . now ( ) - _mdDdOpenedAt < 400 ) return ;
if ( menu . contains ( ev . target ) || toggleBtn . contains ( ev . target ) ) return ;
}
menu . remove ( ) ;
document . removeEventListener ( 'click' , close , true ) ;
document . removeEventListener ( 'keydown' , close , true ) ;
window . removeEventListener ( 'scroll' , close , true ) ;
window . removeEventListener ( 'resize' , close , true ) ;
} ;
setTimeout ( ( ) => {
document . addEventListener ( 'click' , close , true ) ;
document . addEventListener ( 'keydown' , close , true ) ;
window . addEventListener ( 'scroll' , close , true ) ;
window . addEventListener ( 'resize' , close , true ) ;
} , 0 ) ;
}
function initMdToolbar ( ) {
const toolbar = document . getElementById ( 'doc-md-toolbar' ) ;
if ( ! toolbar ) return ;
const itemsWrap = document . getElementById ( 'md-toolbar-items' ) ;
const overflowWrapper = document . getElementById ( 'md-toolbar-overflow-wrapper' ) ;
const overflowToggle = document . getElementById ( 'md-toolbar-overflow-toggle' ) ;
const overflowMenu = document . getElementById ( 'md-toolbar-overflow-menu' ) ;
const undoBtn = document . getElementById ( 'md-toolbar-undo' ) ;
// Click handler for format buttons + the grouped dropdown toggles. The menu
// is appended to <body> (not nested in the toolbar) so the draggable panel's
// CSS transform doesn't reparent its fixed positioning or clip it.
// Keep the editor's focus + selection when a format button / dropdown
// toggle is pressed. Without this the button steals focus on press, which
// collapses the textarea selection (so B/I/S apply to nothing) and, on
// mobile, drops the keyboard — whose viewport resize then instantly closes
// any dropdown that just opened. Preventing the default mousedown keeps the
// textarea focused, so formatting hits the live selection and menus stay up.
toolbar . addEventListener ( 'mousedown' , ( e ) => {
if ( e . target . closest ( '[data-md], .md-dd-toggle, .emoji-picker-btn' ) ) e . preventDefault ( ) ;
} ) ;
toolbar . addEventListener ( 'click' , ( e ) => {
const dd = e . target . closest ( '.md-dd-toggle' ) ;
if ( dd ) { e . preventDefault ( ) ; _showMdDropdown ( dd ) ; return ; }
const btn = e . target . closest ( '[data-md]' ) ;
if ( ! btn ) return ;
e . preventDefault ( ) ;
applyMdFormat ( btn . dataset . md ) ;
} ) ;
// Undo button
if ( undoBtn ) {
undoBtn . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ta ) { ta . focus ( ) ; document . execCommand ( 'undo' ) ; }
} ) ;
}
// Overflow collapse logic
let _mdMenuOpen = false ;
// Horizontal-scroll affordance: the toolbar scrolls its icons; edge arrows
// appear when there's more off either side and smoothly scroll to that edge.
const scrollLeftBtn = document . getElementById ( 'md-scroll-left' ) ;
const scrollRightBtn = document . getElementById ( 'md-scroll-right' ) ;
function updateScrollArrows ( ) {
if ( ! itemsWrap || ! scrollLeftBtn || ! scrollRightBtn ) return ;
const maxScroll = itemsWrap . scrollWidth - itemsWrap . clientWidth ;
const overflowing = maxScroll > 2 ;
scrollLeftBtn . style . display = ( overflowing && itemsWrap . scrollLeft > 1 ) ? 'flex' : 'none' ;
scrollRightBtn . style . display = ( overflowing && itemsWrap . scrollLeft < maxScroll - 1 ) ? 'flex' : 'none' ;
}
scrollLeftBtn ? . addEventListener ( 'click' , ( ) => itemsWrap . scrollTo ( { left : 0 , behavior : 'smooth' } ) ) ;
scrollRightBtn ? . addEventListener ( 'click' , ( ) => itemsWrap . scrollTo ( { left : itemsWrap . scrollWidth , behavior : 'smooth' } ) ) ;
itemsWrap ? . addEventListener ( 'scroll' , updateScrollArrows , { passive : true } ) ;
if ( window . ResizeObserver && itemsWrap ) {
new ResizeObserver ( updateScrollArrows ) . observe ( itemsWrap ) ;
}
function syncMdOverflow ( ) {
if ( overflowWrapper ) overflowWrapper . style . display = 'none' ;
updateScrollArrows ( ) ;
}
function closeMdMenu ( ) {
_mdMenuOpen = false ;
if ( overflowMenu ) overflowMenu . classList . remove ( 'open' ) ;
}
if ( overflowToggle ) {
overflowToggle . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_mdMenuOpen = ! _mdMenuOpen ;
if ( _mdMenuOpen ) {
document . body . appendChild ( overflowMenu ) ;
const rect = overflowToggle . getBoundingClientRect ( ) ;
overflowMenu . style . position = 'fixed' ;
overflowMenu . style . top = ( rect . bottom + 2 ) + 'px' ;
overflowMenu . style . right = ( window . innerWidth - rect . right ) + 'px' ;
overflowMenu . style . left = 'auto' ;
} else {
overflowWrapper . appendChild ( overflowMenu ) ;
}
overflowMenu . classList . toggle ( 'open' , _mdMenuOpen ) ;
} ) ;
}
document . addEventListener ( 'click' , ( ) => {
if ( _mdMenuOpen ) { closeMdMenu ( ) ; overflowWrapper . appendChild ( overflowMenu ) ; }
} ) ;
// Re-check overflow on resize
let _mdResizeTimer ;
window . addEventListener ( 'resize' , ( ) => {
clearTimeout ( _mdResizeTimer ) ;
_mdResizeTimer = setTimeout ( syncMdOverflow , 100 ) ;
} ) ;
// Show toolbar if language is already markdown
const lang = document . getElementById ( 'doc-language-select' ) ? . value ;
if ( lang === 'markdown' ) toolbar . style . display = '' ;
// Initial sync after layout
requestAnimationFrame ( syncMdOverflow ) ;
// Expose for external calls (e.g. after fullscreen toggle)
toolbar . _syncOverflow = syncMdOverflow ;
}
/** Collapse action buttons into overflow "..." menu (3 most-used visible) */
const _DOC _RECENTS _KEY = 'odysseus-doc-actions-recent' ;
const _DOC _MAX _VISIBLE = 2 ;
function _getDocRecent ( ) {
try { return JSON . parse ( localStorage . getItem ( _DOC _RECENTS _KEY ) || '[]' ) ; } catch { return [ ] ; }
}
function _trackDocAction ( id ) {
let recent = _getDocRecent ( ) . filter ( x => x !== id ) ;
recent . unshift ( id ) ;
if ( recent . length > 10 ) recent . length = 10 ;
localStorage . setItem ( _DOC _RECENTS _KEY , JSON . stringify ( recent ) ) ;
}
function initActionOverflow ( ) {
const actionsEl = document . getElementById ( 'doc-editor-actions' ) ;
const wrapper = document . getElementById ( 'doc-overflow-wrapper' ) ;
const toggle = document . getElementById ( 'doc-overflow-toggle' ) ;
const menu = document . getElementById ( 'doc-overflow-menu' ) ;
if ( ! actionsEl || ! wrapper || ! toggle || ! menu ) return ;
const allBtns = Array . from ( actionsEl . querySelectorAll ( '.doc-collapsible-btn' ) ) ;
let _menuOpen = false ;
function syncOverflow ( ) {
allBtns . forEach ( b => { b . classList . remove ( 'doc-collapsed' ) ; } ) ;
menu . innerHTML = '' ;
// Filter to currently visible buttons
const available = allBtns . filter ( b => b . style . display !== 'none' ) ;
// Sort by recent usage, defaults: copy, export, save
const recent = _getDocRecent ( ) ;
const defaults = [ 'doc-copy-btn' , 'doc-export-btn' , 'doc-save-btn' ] ;
const order = recent . length > 0 ? recent : defaults ;
// Auto-pin: md preview when language is markdown
const lang = document . getElementById ( 'doc-language-select' ) ? . value ;
const pinned = [ ] ;
if ( lang === 'markdown' ) {
const mdBtn = available . find ( b => b . id === 'doc-md-btn' ) ;
if ( mdBtn ) pinned . push ( mdBtn ) ;
}
const sorted = [ ... available ] . sort ( ( a , b ) => {
const ai = order . indexOf ( a . id ) , bi = order . indexOf ( b . id ) ;
if ( ai >= 0 && bi >= 0 ) return ai - bi ;
if ( ai >= 0 ) return - 1 ;
if ( bi >= 0 ) return 1 ;
return 0 ;
} ) ;
// Pinned + top N (deduplicated) — pinned count against the max
const visible = [ ... pinned ] ;
for ( const btn of sorted ) {
if ( visible . length >= _DOC _MAX _VISIBLE ) break ;
if ( ! visible . includes ( btn ) ) visible . push ( btn ) ;
}
// Ensure we never exceed MAX_VISIBLE
while ( visible . length > _DOC _MAX _VISIBLE ) visible . pop ( ) ;
const overflow = sorted . filter ( b => ! visible . includes ( b ) ) ;
// Show visible, hide overflow
overflow . forEach ( b => b . classList . add ( 'doc-collapsed' ) ) ;
// Reorder DOM: visible buttons before wrapper
for ( const btn of visible ) {
actionsEl . insertBefore ( btn , wrapper ) ;
}
if ( overflow . length > 0 ) {
wrapper . style . display = '' ;
overflow . forEach ( btn => {
const item = document . createElement ( 'button' ) ;
item . className = 'doc-overflow-item' ;
item . innerHTML = btn . innerHTML + '<span>' + ( btn . title || '' ) + '</span>' ;
item . addEventListener ( 'click' , ( e ) => {
_trackDocAction ( btn . id ) ;
// Export button has its own submenu
if ( btn . id === 'doc-export-btn' ) {
e . stopPropagation ( ) ;
const savedRect = item . getBoundingClientRect ( ) ;
closeMenu ( ) ;
setTimeout ( ( ) => showExportMenu ( null , savedRect ) , 50 ) ;
return ;
}
closeMenu ( ) ;
btn . click ( ) ;
syncOverflow ( ) ; // re-sort with new recency
} ) ;
menu . appendChild ( item ) ;
} ) ;
} else {
wrapper . style . display = 'none' ;
}
}
function closeMenu ( ) {
_menuOpen = false ;
menu . classList . remove ( 'open' ) ;
}
toggle . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_menuOpen = ! _menuOpen ;
if ( _menuOpen ) {
// Move to body to escape overflow:hidden on doc-editor-pane
document . body . appendChild ( menu ) ;
const rect = toggle . getBoundingClientRect ( ) ;
menu . style . position = 'fixed' ;
menu . style . top = ( rect . bottom + 2 ) + 'px' ;
menu . style . right = ( window . innerWidth - rect . right ) + 'px' ;
menu . style . left = 'auto' ;
} else {
wrapper . appendChild ( menu ) ;
}
menu . classList . toggle ( 'open' , _menuOpen ) ;
} ) ;
document . addEventListener ( 'click' , ( ) => {
if ( _menuOpen ) { closeMenu ( ) ; wrapper . appendChild ( menu ) ; }
} ) ;
// Also track when visible buttons are clicked directly
allBtns . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
_trackDocAction ( btn . id ) ;
// Defer re-sort so the click handler fires first
setTimeout ( syncOverflow , 100 ) ;
} ) ;
} ) ;
requestAnimationFrame ( syncOverflow ) ;
_syncOverflow = syncOverflow ;
}
/** Divider drag to resize the editor pane */
function initDividerDrag ( divider , pane , isRight ) {
let dragging = false ;
divider . addEventListener ( 'mousedown' , ( e ) => {
dragging = true ;
document . body . style . cursor = 'col-resize' ;
document . body . style . userSelect = 'none' ;
e . preventDefault ( ) ;
} ) ;
document . addEventListener ( 'mousemove' , ( e ) => {
if ( ! dragging ) return ;
const width = isRight
? e . clientX
: window . innerWidth - e . clientX ;
pane . style . width = Math . max ( 250 , Math . min ( width , window . innerWidth * 0.7 ) ) + 'px' ;
pane . style . flex = 'none' ;
} ) ;
document . addEventListener ( 'mouseup' , ( ) => {
if ( dragging ) {
dragging = false ;
document . body . style . cursor = '' ;
document . body . style . userSelect = '' ;
// Re-sync highlighting and line numbers after resize
syncHighlighting ( ) ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ta ) updateLineNumbers ( ta . value ) ;
}
} ) ;
}
/** Close the editor panel */
// When the doc panel is "tab/chevron down" minimized, it lives as a chip
// in the bottom dock instead of a red toolbar indicator. We remember which
// doc was active so the chip can restore it.
let _minimizedDocId = null ;
function _ensureDocChipRegistered ( ) {
if ( Modals . isRegistered ( 'doc-panel' ) ) return ;
Modals . register ( 'doc-panel' , {
// The ✕ / drag-to-trash on the minimized chip is a real close — detach
// the doc from the chat session so it doesn't reappear in that chat.
closeFn : ( ) => {
// Content was already saved to the map when the panel was minimized,
// so just detach (don't re-read the now-removed editor).
const id = _minimizedDocId ;
_minimizedDocId = null ;
if ( id ) _detachDocFromSession ( id ) ;
} ,
restoreFn : ( ) => {
const id = _minimizedDocId ;
_minimizedDocId = null ;
// openPanel builds the pane shell; switchToDoc re-renders the
// saved doc content into it (including PDF render-pages, syntax
// highlighting, etc.). Without switchToDoc, the pane is empty.
openPanel ( ) ;
if ( id && docs . has ( id ) ) {
try { switchToDoc ( id ) ; } catch ( e ) { console . error ( 'Restore doc failed:' , e ) ; }
}
} ,
} ) ;
}
export function closePanel ( direction ) {
if ( ! isOpen ) {
if ( direction !== 'down' && Modals . isRegistered ( 'doc-panel' ) ) {
_minimizedDocId = null ;
_markDocVisibleState ( _lastSessionId , 'closed' ) ;
Modals . unregister ( 'doc-panel' ) ;
}
return ;
}
isOpen = false ;
// On touch, closing the doc should leave the keyboard DOWN. The tap blurs
// the textarea (keyboard starts down), but a stray refocus during teardown
// (the view behind regaining focus, etc.) was bouncing it back up. Blur any
// focused field now and again after the close settles to keep it down.
if ( direction !== 'down' && ( ( 'ontouchstart' in window ) || ( navigator . maxTouchPoints || 0 ) > 0 ) ) {
const _dropKb = ( ) => {
const ae = document . activeElement ;
if ( ae && ( ae . tagName === 'INPUT' || ae . tagName === 'TEXTAREA' ) ) { try { ae . blur ( ) ; } catch ( _ ) { } }
} ;
_dropKb ( ) ;
requestAnimationFrame ( _dropKb ) ;
setTimeout ( _dropKb , 80 ) ;
}
// Save current state
saveCurrentToMap ( ) ;
// A "down" close means minimize, not close. Register the chip and flip
// the dock state to minimized so a chip appears at the bottom. Any
// other direction is a real close — make sure any leftover chip from a
// prior minimize cycle is cleared too.
if ( direction === 'down' ) {
_minimizedDocId = activeDocId ;
_markDocVisibleState ( _lastSessionId , 'minimized' ) ;
_ensureDocChipRegistered ( ) ;
Modals . minimize ( 'doc-panel' ) ;
} else if ( Modals . isRegistered ( 'doc-panel' ) ) {
_minimizedDocId = null ;
_markDocVisibleState ( _lastSessionId , 'closed' ) ;
Modals . unregister ( 'doc-panel' ) ;
} else {
_markDocVisibleState ( _lastSessionId , 'closed' ) ;
}
const pane = document . getElementById ( 'doc-editor-pane' ) ;
const divider = document . getElementById ( 'doc-divider' ) ;
const _finishClose = ( ) => {
// If the panel was reopened during the slide-out animation (close →
// reopen fast, e.g. close a draft then immediately compose a new one),
// bail — otherwise this stale close strips doc-view after the new open
// re-added it, and the fresh pane drops into the desktop split layout
// (renders as a narrow "sidebar" on mobile).
if ( isOpen ) { if ( pane ) pane . remove ( ) ; if ( divider ) divider . remove ( ) ; return ; }
document . body . classList . remove ( 'doc-view' ) ;
const container = document . getElementById ( 'chat-container' ) ;
if ( container ) container . style . display = '' ;
if ( pane ) pane . remove ( ) ;
if ( divider ) divider . remove ( ) ;
activeDocId = null ;
const btn = document . getElementById ( 'overflow-doc-btn' ) ;
if ( btn ) btn . classList . remove ( 'active' ) ;
const docInd = document . getElementById ( 'doc-indicator-btn' ) ;
if ( docInd ) docInd . classList . remove ( 'active' ) ;
} ;
if ( pane ) {
// Determine slide direction
let transform ;
if ( direction === 'down' ) {
// Full slide off-screen on mobile (sheet dismiss); small nudge on desktop.
transform = window . innerWidth <= 768 ? 'translateY(100%)' : 'translateY(30px)' ;
} else {
const fromLeft = pane . classList . contains ( 'doc-left' ) ;
transform = fromLeft ? 'translateX(-40px)' : 'translateX(40px)' ;
}
pane . style . transition = 'transform 0.15s ease-in, opacity 0.1s ease-in' ;
pane . style . transform = transform ;
pane . style . opacity = '0' ;
if ( divider ) { divider . style . transition = 'opacity 0.1s ease-in' ; divider . style . opacity = '0' ; }
pane . addEventListener ( 'transitionend' , _finishClose , { once : true } ) ;
// Safety fallback
setTimeout ( _finishClose , 200 ) ;
} else {
_finishClose ( ) ;
}
}
/** Swap doc panel side (called when sidebar side changes) */
export function swapSide ( ) {
if ( ! isOpen ) return ;
const pane = document . getElementById ( 'doc-editor-pane' ) ;
const divider = document . getElementById ( 'doc-divider' ) ;
const container = document . getElementById ( 'chat-container' ) ;
if ( ! pane || ! divider || ! container ) return ;
const sidebar = document . getElementById ( 'sidebar' ) ;
const isRight = sidebar && sidebar . classList . contains ( 'right-side' ) ;
if ( isRight ) {
// Sidebar moved right → doc goes left (before chat)
pane . classList . add ( 'doc-left' ) ;
container . parentNode . insertBefore ( pane , container ) ;
container . parentNode . insertBefore ( divider , container ) ;
} else {
// Sidebar moved left → doc goes right (after chat)
pane . classList . remove ( 'doc-left' ) ;
container . after ( divider ) ;
divider . after ( pane ) ;
}
// Re-init divider drag for the new side
initDividerDrag ( divider , pane , isRight ) ;
}
// ---- Document CRUD ----
/** Create a new document for the current session */
// Create a new blank document, reusing the current/last session or
// auto-creating one. Same flow as the tab-bar "+" — the single entry point
// the sidebar Library "+" should use too.
export async function newDocument ( ) {
let sessionId = docs . get ( activeDocId ) ? . sessionId
|| _lastSessionId
|| ( sessionModule && sessionModule . getCurrentSessionId ( ) ) ;
if ( ! sessionId ) {
try { sessionId = await _autoCreateSession ( ) ; }
catch ( e ) { console . error ( 'Failed to auto-create session for document:' , e ) ; return ; }
}
await createDocument ( sessionId ) ;
}
export async function createDocument ( sessionId ) {
if ( _creatingDoc ) return ;
_creatingDoc = true ;
// If the panel was in empty-state, the user may type into the editor
// during the create round-trip — preserve that text into the new doc
// instead of letting switchToDoc blank it.
const wasEmpty = ! activeDocId ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
session _id : sessionId ,
title : '' ,
content : '' ,
language : 'markdown' ,
} ) ,
} ) ;
const doc = await res . json ( ) ;
addDocToTabs ( doc , sessionId ) ;
if ( ! isOpen ) openPanel ( ) ;
// Re-enable editor if it was in empty state
let textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) {
textarea . disabled = false ;
textarea . placeholder = 'Document content...' ;
}
// Capture text typed during the round-trip (only when starting from the
// empty editor — don't steal another doc's content).
const typed = ( wasEmpty && textarea && textarea . value . trim ( ) ) ? textarea . value : '' ;
switchToDoc ( doc . id ) ;
if ( typed ) {
textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) textarea . value = typed ;
const d = docs . get ( doc . id ) ;
if ( d ) d . content = typed ;
syncHighlighting ( ) ;
clearTimeout ( _autoSaveDebounce ) ;
_autoSaveDebounce = setTimeout ( ( ) => { saveDocument ( { silent : true } ) ; } , 800 ) ;
}
textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) textarea . focus ( ) ;
} catch ( e ) {
console . error ( 'Failed to create document:' , e ) ;
if ( uiModule ) uiModule . showError ( 'Failed to create document' ) ;
} finally {
_creatingDoc = false ;
}
}
/** Load an existing document into a tab */
/ * * I n j e c t a f r e s h l y - c r e a t e d d o c d i c t ( f r o m a P O S T r e s p o n s e ) d i r e c t l y i n t o
* the tabs without re - fetching it via GET . Fixes a race where GET
* / a p i / d o c u m e n t / { i d } c a n 4 0 4 r i g h t a f t e r a s u c c e s s f u l P O S T — w e a l r e a d y
* have the full doc payload from the create response , no need to round - trip .
* /
export function injectFreshDoc ( doc ) {
if ( ! doc || ! doc . id ) return ;
const sessionId = doc . session _id || _lastSessionId || null ;
addDocToTabs ( doc , sessionId ) ;
// Use _ensureDocPaneMounted (not `if (!isOpen) openPanel()`): when a draft
// is composed from the email modal, `isOpen` can be stale-true while the
// actual pane was torn down — a bare openPanel() early-returns and the doc
// mounts into a wrong/half-built pane (rendered as a narrow sidebar on
// mobile instead of its own full-screen window). This remounts it cleanly.
_ensureDocPaneMounted ( ) ;
// Defer to next frame so the panel DOM exists before switchToDoc populates
requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => {
switchToDoc ( doc . id ) ;
} ) ) ;
}
2026-06-01 20:56:11 +09:00
export async function replaceEmailReplyBody ( docId , replyText ) {
const doc = docs . get ( docId ) ;
if ( ! doc ) return ;
const fields = _parseEmailHeader ( doc . content || '' ) ;
const lines = String ( fields . body || '' ) . split ( '\n' ) ;
const quoteIdx = lines . findIndex ( line =>
/^-{5,}\s*Previous message\s*-{5,}$/i . test ( line . trim ( ) )
|| /^On .+ wrote:\s*$/i . test ( line . trim ( ) )
) ;
const quote = quoteIdx >= 0 ? lines . slice ( quoteIdx ) . join ( '\n' ) : '' ;
const ownText = _emailReplyOwnText ( fields . body || '' ) ;
if ( ownText && ! /^(\[AI reply draft will appear here\]|Drafting AI reply)/i . test ( ownText ) ) {
if ( uiModule ) uiModule . showToast ( 'AI reply ready, but draft was edited' ) ;
return ;
}
const body = String ( replyText || '' ) . trim ( ) + ( quote ? ` \n \n ${ quote } ` : '' ) ;
doc . content = _buildEmailContent (
fields . to ,
fields . subject ,
fields . inReplyTo ,
fields . references ,
body ,
fields . sourceUid ,
fields . sourceFolder ,
fields . cc ,
fields . bcc ,
) ;
if ( activeDocId === docId ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) await _streamEmailBodyText ( textarea , body ) ;
}
clearTimeout ( _autoSaveDebounce ) ;
_autoSaveDebounce = setTimeout ( ( ) => { saveDocument ( { silent : true } ) ; } , 800 ) ;
}
2026-05-31 23:58:26 +09:00
// Force the panel into a genuinely-open state. `isOpen` can be true while the
// pane was torn down by another full-screen view (e.g. opening a doc from the
// email modal): in that case openPanel() early-returns and nothing mounts, so
// the doc silently never appears. Reset the stale flag and re-open for real.
function _ensureDocPaneMounted ( ) {
if ( ! isOpen || ! document . getElementById ( 'doc-editor-pane' ) ) {
isOpen = false ;
openPanel ( ) ;
}
}
export async function loadDocument ( docId ) {
// If already in tabs, just switch
if ( docs . has ( docId ) ) {
_ensureDocPaneMounted ( ) ;
switchToDoc ( docId ) ;
return ;
}
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ docId } ` ) ;
2026-06-02 04:48:54 +02:00
if ( ! res . ok ) throw new Error ( res . status === 404 ? 'Not found' : ` HTTP ${ res . status } ` ) ;
2026-05-31 23:58:26 +09:00
const doc = await res . json ( ) ;
addDocToTabs ( doc , doc . session _id ) ;
_ensureDocPaneMounted ( ) ;
switchToDoc ( doc . id ) ;
} catch ( e ) {
console . error ( 'Failed to load document:' , e ) ;
2026-06-02 04:48:54 +02:00
if ( uiModule ) {
const msg = e . message === 'Not found'
? 'Document not found — try opening it from the Library.'
: 'Could not open document.' ;
uiModule . showError ( msg ) ;
}
2026-05-31 23:58:26 +09:00
}
}
2026-06-02 04:48:54 +02:00
// Deep-link: #document-<id> opens that document on load / URL-bar nav.
// Clicks on in-chat document anchors are handled separately (they call
// preventDefault, so they don't change the hash); this covers refresh
// and pasted/typed document URLs, which previously did nothing.
function _maybeOpenDocFromHash ( ) {
const m = ( window . location . hash || '' ) . match ( /^#document-(.+)$/ ) ;
if ( m ) loadDocument ( m [ 1 ] ) ;
}
2026-05-31 23:58:26 +09:00
/** Open panel and ensure a document exists, creating a session if needed */
export async function ensureDocPanel ( ) {
let sessionId = _lastSessionId
|| ( sessionModule && sessionModule . getCurrentSessionId ( ) ) ;
if ( ! sessionId ) {
try {
sessionId = await _autoCreateSession ( ) ;
} catch ( e ) {
console . error ( 'Failed to auto-create session for document:' , e ) ;
openPanel ( ) ;
return ;
}
}
await loadSessionDocs ( sessionId ) ;
}
/** Create a session and sync it with the sessions module */
async function _autoCreateSession ( ) {
// Materialize pending chat first if one exists
if ( sessionModule && sessionModule . hasPendingChat && sessionModule . hasPendingChat ( ) ) {
await sessionModule . materializePendingSession ( ) ;
const id = sessionModule . getCurrentSessionId ( ) ;
if ( id ) { _lastSessionId = id ; return id ; }
}
// Preserve the current model when creating a doc session
const curModel = sessionModule ? . getCurrentModel ? sessionModule . getCurrentModel ( ) : null ;
const sessions = sessionModule ? sessionModule . getSessions ( ) : [ ] ;
const match = curModel && sessions . find ( s => s . model === curModel && s . endpoint _url ) ;
const fd = new FormData ( ) ;
fd . append ( 'name' , ` Notes ${ new Date ( ) . toLocaleTimeString ( ) } ` ) ;
fd . append ( 'skip_validation' , 'true' ) ;
if ( match ) {
fd . append ( 'endpoint_url' , match . endpoint _url ) ;
fd . append ( 'model' , match . model ) ;
if ( match . endpoint _id ) fd . append ( 'endpoint_id' , match . endpoint _id ) ;
}
const res = await fetch ( ` ${ API _BASE } /api/session ` , { method : 'POST' , body : fd } ) ;
if ( ! res . ok ) throw new Error ( 'Session create failed' ) ;
const payload = await res . json ( ) ;
const sessionId = payload . id ;
_lastSessionId = sessionId ;
// Tell sessions module so chat uses the same session
if ( sessionModule && sessionModule . setCurrentSessionId ) {
sessionModule . setCurrentSessionId ( sessionId ) ;
}
if ( sessionModule && sessionModule . loadSessions ) sessionModule . loadSessions ( ) ;
return sessionId ;
}
/** Load all documents for a session into tabs */
export async function loadSessionDocs ( sessionId , opts = { } ) {
_lastSessionId = sessionId ;
const restoreMode = ! ! opts . restoreMode ;
const shouldRestoreOpen = localStorage . getItem ( _docOpenKey ( sessionId ) ) === '1' ;
const shouldRestoreMinimized = localStorage . getItem ( _docMinimizedKey ( sessionId ) ) === '1' ;
// Clear docs from other sessions so tabs are per-session,
// but keep session-less docs (e.g. email compose) — they're independent
for ( const [ id , doc ] of [ ... docs ] ) {
if ( doc . sessionId && doc . sessionId !== sessionId ) docs . delete ( id ) ;
}
activeDocId = null ;
// Show loading state while fetching
if ( isOpen ) _showLoadingOverlay ( ) ;
try {
const res = await fetch ( ` ${ API _BASE } /api/documents/ ${ sessionId } ` ) ;
const allDocs = await res . json ( ) ;
_hideLoadingOverlay ( ) ;
// Only load active docs
const activeDocs = allDocs . filter ( d => d . is _active ) ;
if ( activeDocs . length === 0 ) {
// No docs yet — show empty editor, doc will be created when user types
if ( ! restoreMode || shouldRestoreOpen ) {
if ( ! isOpen ) openPanel ( ) ;
showEmptyState ( ) ;
renderTabs ( ) ;
}
return ;
}
for ( const doc of activeDocs ) {
if ( ! docs . has ( doc . id ) ) {
addDocToTabs ( doc , sessionId ) ;
}
}
_syncDocIndicator ( ) ;
// Switch to the most recently active one (or first)
const target = activeDocs [ 0 ] ;
if ( restoreMode && shouldRestoreMinimized && ! shouldRestoreOpen ) {
activeDocId = null ;
_minimizedDocId = target . id ;
_markDocVisibleState ( sessionId , 'minimized' ) ;
_ensureDocChipRegistered ( ) ;
Modals . minimize ( 'doc-panel' ) ;
return ;
}
// Removed: the old "if restoreMode && !shouldRestoreOpen → stay
// closed" branch. Users expect that entering a chat with an
// attached document opens the panel automatically, not just shows
// an indicator. The minimised branch above still respects an
// explicit user choice to dock the panel; everything else falls
// through to the "open panel" path below.
if ( false ) {
activeDocId = null ;
_minimizedDocId = null ;
if ( Modals . isRegistered ( 'doc-panel' ) ) Modals . unregister ( 'doc-panel' ) ;
return ;
}
// Always open when there are docs — the minimised branch above
// already returned for users who explicitly docked the panel.
// The previous `if (!restoreMode || shouldRestoreOpen)` gate left
// the panel closed on first entry to a chat with docs, which
// hides the doc unless the user manually opens the panel.
_markDocVisibleState ( sessionId , 'open' ) ;
if ( ! isOpen ) openPanel ( ) ;
switchToDoc ( target . id ) ;
} catch ( e ) {
_hideLoadingOverlay ( ) ;
console . error ( 'Failed to load session documents:' , e ) ;
// Open empty panel on error too
if ( ! isOpen ) openPanel ( ) ;
showEmptyState ( ) ;
}
}
/** Add a document to the tabs map */
function addDocToTabs ( doc , sessionId ) {
const existing = docs . get ( doc . id ) ;
docs . set ( doc . id , {
id : doc . id ,
title : doc . title || '' ,
language : doc . language || '' ,
content : doc . current _content || '' ,
version : doc . version _count || 1 ,
sessionId : sessionId || doc . session _id ,
userSetLanguage : ! ! doc . language ,
_composeAtts : existing ? . _composeAtts ,
// Provenance for the "Send signed reply" flow
sourceEmailUid : doc . source _email _uid || null ,
sourceEmailFolder : doc . source _email _folder || null ,
sourceEmailAccountId : doc . source _email _account _id || null ,
sourceEmailMessageId : doc . source _email _message _id || null ,
} ) ;
}
/** Populate the editor with document data (used internally) */
function populateEditor ( doc ) {
const titleInput = document . getElementById ( 'doc-title-input' ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const langSelect = document . getElementById ( 'doc-language-select' ) ;
const badge = document . getElementById ( 'doc-version-badge' ) ;
if ( titleInput ) titleInput . value = doc . title || '' ;
if ( textarea ) textarea . value = doc . current _content || doc . content || '' ;
if ( langSelect ) langSelect . value = doc . language || 'markdown' ;
if ( badge ) { const _v = doc . version _count || doc . version || 1 ; badge . textContent = ` v ${ _v } ` ; badge . style . display = _v > 1 ? '' : 'none' ; }
{ const _v = doc . version _count || doc . version || 1 ; const _dbtn = document . getElementById ( 'doc-diff-toggle-btn' ) ; if ( _dbtn ) _dbtn . style . display = _v > 1 ? '' : 'none' ; }
syncHighlighting ( ) ;
}
/** Post-process hljs markdown output: colorize [brackets] and heading # markers */
function _postProcessMarkdown ( codeEl ) {
const walker = document . createTreeWalker ( codeEl , NodeFilter . SHOW _TEXT ) ;
const textNodes = [ ] ;
while ( walker . nextNode ( ) ) textNodes . push ( walker . currentNode ) ;
for ( const node of textNodes ) {
const text = node . textContent ;
// Skip nodes already inside hljs spans (like [link text] which is .hljs-string)
if ( node . parentElement !== codeEl && node . parentElement . className &&
/hljs-(string|link|code|section)/ . test ( node . parentElement . className ) ) continue ;
// Match standalone [bracketed text] not followed by (url)
if ( /\[[^\]]+\](?!\()/ . test ( text ) ) {
const frag = document . createDocumentFragment ( ) ;
let last = 0 ;
const re = /\[([^\]]+)\](?!\()/g ;
let m ;
while ( ( m = re . exec ( text ) ) !== null ) {
if ( m . index > last ) frag . appendChild ( document . createTextNode ( text . slice ( last , m . index ) ) ) ;
const span = document . createElement ( 'span' ) ;
span . className = 'md-bracket' ;
span . textContent = m [ 0 ] ;
frag . appendChild ( span ) ;
last = re . lastIndex ;
}
if ( last < text . length ) frag . appendChild ( document . createTextNode ( text . slice ( last ) ) ) ;
if ( last > 0 ) node . parentNode . replaceChild ( frag , node ) ;
}
}
// Colorize heading # markers inside .hljs-section spans
codeEl . querySelectorAll ( '.hljs-section' ) . forEach ( span => {
const text = span . textContent ;
const hashMatch = text . match ( /^(#{1,6})\s/ ) ;
if ( hashMatch ) {
const marker = document . createElement ( 'span' ) ;
marker . className = 'md-heading-marker' ;
marker . textContent = hashMatch [ 1 ] + ' ' ;
span . textContent = text . slice ( hashMatch [ 0 ] . length ) ;
span . prepend ( marker ) ;
}
} ) ;
}
// Find-result rectangles drawn ON TOP of the textarea — bypasses
// the syntax-highlight overlay entirely so visibility works in
// markdown, email, and any other mode regardless of single-layer-
// rendering quirks. Same mirror-measurement approach as pinned
// selections so wrap matches the textarea exactly.
//
// `matches` is an array of [start, end] offsets; `currentIdx` is
// the focused one (gets brighter accent). Pass empty matches to
// clear all rects.
function renderFindRects ( matches , currentIdx ) {
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( ! wrap ) return ;
wrap . querySelectorAll ( '.doc-find-rect' ) . forEach ( el => el . remove ( ) ) ;
if ( ! matches || matches . length === 0 ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
const text = textarea . value ;
const style = getComputedStyle ( textarea ) ;
const paddingTop = parseFloat ( style . paddingTop ) || 10 ;
const paddingLeft = parseFloat ( style . paddingLeft ) || 48 ;
const lineHeight = parseFloat ( style . lineHeight ) || ( parseFloat ( style . fontSize ) * 1.45 ) ;
let mirror = document . getElementById ( 'doc-find-rect-mirror' ) ;
if ( ! mirror ) {
mirror = document . createElement ( 'div' ) ;
mirror . id = 'doc-find-rect-mirror' ;
mirror . style . cssText = 'position:absolute;top:0;left:0;right:0;visibility:hidden;pointer-events:none;' +
'white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;overflow:hidden;box-sizing:border-box;' ;
wrap . appendChild ( mirror ) ;
}
mirror . style . font = style . font ;
mirror . style . padding = style . padding ;
mirror . style . borderWidth = style . borderWidth ;
mirror . style . borderStyle = 'solid' ;
mirror . style . borderColor = 'transparent' ;
mirror . style . width = textarea . clientWidth + 'px' ;
mirror . style . tabSize = style . tabSize ;
mirror . style . letterSpacing = style . letterSpacing ;
mirror . style . wordSpacing = style . wordSpacing ;
mirror . style . textIndent = style . textIndent ;
const scrollTop = textarea . scrollTop ;
for ( let i = 0 ; i < matches . length ; i ++ ) {
const [ s , e ] = matches [ i ] ;
// Line-band style: highlight the FULL visual row containing the
// match. Cheap, always-visible, doesn't need character-precise
// mirror measurement that varies across email/markdown/code modes.
mirror . textContent = text . substring ( 0 , s ) ;
const startTop = mirror . scrollHeight - paddingTop ;
// Find the wrap-row's end by measuring with one extra char beyond
// the match end and stepping back to the last whitespace boundary.
mirror . textContent = text . substring ( 0 , e ) ;
const endHeight = mirror . scrollHeight - paddingTop ;
mirror . textContent = '' ;
const top = paddingTop + startTop - scrollTop ;
const height = Math . max ( endHeight - startTop , lineHeight ) ;
const rect = document . createElement ( 'div' ) ;
rect . className = 'doc-find-rect' + ( i === currentIdx ? ' current' : '' ) ;
rect . style . cssText =
` position:absolute;left: ${ paddingLeft } px;right:8px; ` +
` top: ${ top } px;height: ${ height } px; ` +
` pointer-events:none;z-index:6;border-radius:2px; ` ;
wrap . appendChild ( rect ) ;
}
}
/ * * W r a p f i n d - m a t c h e s i n t h e s y n t a x - h i g h l i g h t e d o v e r l a y w i t h < m a r k > s p a n s .
* Walks text nodes so existing hljs spans are preserved . Matches that cross
* syntax tokens are skipped ( rare for user searches ) . * /
function applyFindMarks ( codeEl ) {
if ( ! codeEl ) return ;
// Remove prior find marks (unwrap)
codeEl . querySelectorAll ( 'mark.doc-find-mark' ) . forEach ( m => {
const parent = m . parentNode ;
while ( m . firstChild ) parent . insertBefore ( m . firstChild , m ) ;
parent . removeChild ( m ) ;
parent . normalize ( ) ;
} ) ;
const q = codeEl . dataset . findQuery || '' ;
if ( ! q ) return ;
const currentIdx = parseInt ( codeEl . dataset . findCurrent || '-1' , 10 ) ;
const lq = q . toLowerCase ( ) ;
let occurrence = 0 ;
const walker = document . createTreeWalker ( codeEl , NodeFilter . SHOW _TEXT , null ) ;
const nodes = [ ] ;
let n ;
while ( ( n = walker . nextNode ( ) ) ) nodes . push ( n ) ;
for ( const node of nodes ) {
const val = node . nodeValue || '' ;
const lv = val . toLowerCase ( ) ;
if ( ! lv . includes ( lq ) ) continue ;
const frag = document . createDocumentFragment ( ) ;
let i = 0 ;
while ( i < val . length ) {
const hit = lv . indexOf ( lq , i ) ;
if ( hit < 0 ) { frag . appendChild ( document . createTextNode ( val . slice ( i ) ) ) ; break ; }
if ( hit > i ) frag . appendChild ( document . createTextNode ( val . slice ( i , hit ) ) ) ;
const mark = document . createElement ( 'mark' ) ;
mark . className = 'doc-find-mark' + ( occurrence === currentIdx ? ' current' : '' ) ;
mark . textContent = val . slice ( hit , hit + q . length ) ;
frag . appendChild ( mark ) ;
occurrence ++ ;
i = hit + q . length ;
}
node . parentNode . replaceChild ( frag , node ) ;
}
}
/** Sync highlighted overlay with textarea content */
function syncHighlighting ( ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
const pre = document . getElementById ( 'doc-editor-highlight' ) ;
if ( ! textarea || ! codeEl ) return ;
// Don't overwrite inline diff markers
if ( codeEl . dataset . hasDiff ) return ;
const text = textarea . value ;
// Trailing newline prevents scroll mismatch on last line
codeEl . textContent = text + '\n' ;
const lang = document . getElementById ( 'doc-language-select' ) ? . value ;
// hljs has no 'svg' grammar — highlight it as xml (the dropdown value stays
// 'svg' so the preview/run routing still treats it as renderable markup).
const _hlLang = lang === 'svg' ? 'xml' : lang ;
codeEl . className = _hlLang ? ` language- ${ _hlLang } ` : '' ;
if ( window . hljs && _hlLang ) {
codeEl . removeAttribute ( 'data-highlighted' ) ;
window . hljs . highlightElement ( codeEl ) ;
}
// Markdown post-processing: colorize standalone [brackets] and heading markers
if ( lang === 'markdown' ) {
_postProcessMarkdown ( codeEl ) ;
}
// Reapply find highlights after hljs rewrote the DOM
if ( codeEl . dataset . findQuery ) applyFindMarks ( codeEl ) ;
// Keep scroll in sync
if ( pre ) {
codeEl . style . minHeight = textarea . scrollHeight + 'px' ;
pre . scrollTop = textarea . scrollTop ;
pre . scrollLeft = textarea . scrollLeft ;
}
// Update line numbers
updateLineNumbers ( text ) ;
}
/** Update the line number gutter */
2026-06-03 07:40:19 +03:00
let _lineNumberResizeObserver = null ;
let _lineNumberObservedTextarea = null ;
let _lineNumberResizeRaf = null ;
function _lineNumberContentEl ( gutter ) {
let inner = gutter . querySelector ( '.doc-line-number-content' ) ;
if ( ! inner ) {
inner = document . createElement ( 'div' ) ;
inner . className = 'doc-line-number-content' ;
gutter . textContent = '' ;
gutter . appendChild ( inner ) ;
}
return inner ;
}
function _lineNumberStyleSignature ( style ) {
return [
style . fontFamily ,
style . fontSize ,
style . fontWeight ,
style . fontStyle ,
style . lineHeight ,
style . letterSpacing ,
style . tabSize ,
style . fontFeatureSettings ,
style . fontVariantLigatures ,
style . fontKerning ,
] . join ( '|' ) ;
}
function _textareaTextWidth ( textarea , style ) {
const paddingLeft = parseFloat ( style . paddingLeft ) || 0 ;
const paddingRight = parseFloat ( style . paddingRight ) || 0 ;
return Math . max ( 0 , textarea . clientWidth - paddingLeft - paddingRight ) ;
}
function _lineHeightPx ( style ) {
const parsed = parseFloat ( style . lineHeight ) ;
if ( Number . isFinite ( parsed ) && parsed > 0 ) return parsed ;
const fontSize = parseFloat ( style . fontSize ) || 11 ;
return fontSize * 1.45 ;
}
function _lineNumberMeasureEl ( textarea ) {
const wrap = document . getElementById ( 'doc-editor-wrap' ) || textarea . parentElement || document . body ;
let probe = wrap . querySelector ( '.doc-line-number-measure' ) ;
if ( ! probe ) {
probe = document . createElement ( 'textarea' ) ;
probe . className = 'doc-line-number-measure' ;
probe . setAttribute ( 'aria-hidden' , 'true' ) ;
probe . tabIndex = - 1 ;
probe . readOnly = true ;
probe . wrap = 'soft' ;
wrap . appendChild ( probe ) ;
}
return probe ;
}
function _syncLineNumberMeasureStyle ( probe , style , textWidth ) {
probe . style . width = textWidth + 'px' ;
probe . style . fontFamily = style . fontFamily ;
probe . style . fontSize = style . fontSize ;
probe . style . fontWeight = style . fontWeight ;
probe . style . fontStyle = style . fontStyle ;
probe . style . lineHeight = style . lineHeight ;
probe . style . letterSpacing = style . letterSpacing ;
probe . style . tabSize = style . tabSize ;
probe . style . fontFeatureSettings = style . fontFeatureSettings ;
probe . style . fontVariantLigatures = style . fontVariantLigatures ;
probe . style . fontKerning = style . fontKerning ;
probe . style . textRendering = style . textRendering ;
probe . style . whiteSpace = style . whiteSpace ;
probe . style . wordWrap = style . wordWrap ;
probe . style . overflowWrap = style . overflowWrap ;
}
function _measureLineNumberHeights ( textarea , lines , textWidth , style ) {
const probe = _lineNumberMeasureEl ( textarea ) ;
_syncLineNumberMeasureStyle ( probe , style , textWidth ) ;
const lineHeight = _lineHeightPx ( style ) ;
return lines . map ( line => {
probe . value = line || ' ' ;
const visualRows = Math . max ( 1 , Math . round ( probe . scrollHeight / lineHeight ) ) ;
return visualRows * lineHeight ;
} ) ;
}
function _renderLineNumberRows ( inner , heights ) {
const frag = document . createDocumentFragment ( ) ;
for ( let i = 0 ; i < heights . length ; i ++ ) {
const row = document . createElement ( 'div' ) ;
row . className = 'doc-line-number-row' ;
row . style . height = ` ${ heights [ i ] } px ` ;
const label = document . createElement ( 'span' ) ;
label . className = 'doc-line-number-label' ;
label . textContent = String ( i + 1 ) ;
row . appendChild ( label ) ;
frag . appendChild ( row ) ;
}
inner . textContent = '' ;
inner . appendChild ( frag ) ;
}
function _scheduleLineNumberRerender ( ) {
if ( _lineNumberResizeRaf ) return ;
const run = ( ) => {
_lineNumberResizeRaf = null ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) updateLineNumbers ( textarea . value , true ) ;
} ;
if ( typeof requestAnimationFrame === 'function' ) {
_lineNumberResizeRaf = requestAnimationFrame ( run ) ;
} else {
run ( ) ;
}
}
function _ensureLineNumberResizeObserver ( textarea ) {
if ( typeof ResizeObserver === 'undefined' ) return ;
if ( ! _lineNumberResizeObserver ) {
_lineNumberResizeObserver = new ResizeObserver ( _scheduleLineNumberRerender ) ;
}
if ( _lineNumberObservedTextarea === textarea ) return ;
if ( _lineNumberObservedTextarea ) {
_lineNumberResizeObserver . unobserve ( _lineNumberObservedTextarea ) ;
}
_lineNumberObservedTextarea = textarea ;
_lineNumberResizeObserver . observe ( textarea ) ;
}
if ( typeof window !== 'undefined' ) {
window . addEventListener ( 'resize' , _scheduleLineNumberRerender ) ;
}
function updateLineNumbers ( text , force = false ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
2026-05-31 23:58:26 +09:00
const gutter = document . getElementById ( 'doc-line-numbers' ) ;
2026-06-03 07:40:19 +03:00
if ( ! textarea || ! gutter ) return ;
const value = text || '' ;
const lines = value . split ( '\n' ) ;
const inner = _lineNumberContentEl ( gutter ) ;
const style = getComputedStyle ( textarea ) ;
const textWidth = _textareaTextWidth ( textarea , style ) ;
const styleSig = _lineNumberStyleSignature ( style ) ;
_ensureLineNumberResizeObserver ( textarea ) ;
if (
! force &&
inner . _lineNumberText === value &&
inner . _lineNumberWidth === textWidth &&
inner . _lineNumberStyleSig === styleSig
) {
syncGutterScroll ( ) ;
return ;
}
const heights = _measureLineNumberHeights ( textarea , lines , textWidth , style ) ;
_renderLineNumberRows ( inner , heights ) ;
inner . _lineNumberText = value ;
inner . _lineNumberWidth = textWidth ;
inner . _lineNumberStyleSig = styleSig ;
syncGutterScroll ( ) ;
2026-05-31 23:58:26 +09:00
}
/** Sync line number gutter scroll with textarea */
function syncGutterScroll ( ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const gutter = document . getElementById ( 'doc-line-numbers' ) ;
if ( textarea && gutter ) {
2026-06-03 07:40:19 +03:00
_lineNumberContentEl ( gutter ) . style . transform = ` translateY( ${ - textarea . scrollTop } px) ` ;
2026-05-31 23:58:26 +09:00
}
}
/** Attempt language auto-detection using hljs.highlightAuto() */
/** Quick heuristic check for markdown before falling back to hljs */
function _looksLikeMarkdown ( text ) {
const lines = text . slice ( 0 , 2000 ) . split ( '\n' ) ;
let score = 0 ;
for ( const line of lines ) {
if ( /^#{1,6}\s/ . test ( line ) ) score += 3 ; // headings
else if ( /^\s*[-*+]\s/ . test ( line ) ) score += 1 ; // list items
else if ( /^\s*\d+\.\s/ . test ( line ) ) score += 1 ; // ordered list
else if ( /^\s*>/ . test ( line ) ) score += 1 ; // blockquote
else if ( /\[.+\]\(.+\)/ . test ( line ) ) score += 2 ; // links
else if ( /^```/ . test ( line ) ) score += 2 ; // fenced code
else if ( /\*\*.+\*\*/ . test ( line ) ) score += 1 ; // bold
else if ( /^---\s*$/ . test ( line ) ) score += 1 ; // horizontal rule
}
return score >= 3 ;
}
function attemptAutoDetect ( ) {
if ( ! window . hljs || ! activeDocId ) return ;
const doc = docs . get ( activeDocId ) ;
if ( ! doc || doc . userSetLanguage ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
const text = textarea . value ;
if ( text . length < AUTO _DETECT _MIN _CHARS ) return ;
// SVG heuristic — a standalone <svg> root (optionally after an XML decl /
// doctype). hljs would tag this generic "xml"; we want it labeled svg so it
// routes to the preview iframe with a correct type.
if ( /^\s*(<\?xml[^>]*>\s*)?(<!doctype[^>]*>\s*)?<svg[\s>]/i . test ( text ) ) {
const langSelect = document . getElementById ( 'doc-language-select' ) ;
if ( langSelect && langSelect . value !== 'svg' ) {
langSelect . value = 'svg' ;
doc . language = 'svg' ;
updateLanguage ( ) ;
syncHighlighting ( ) ;
_syncHeaderActions ( ) ;
}
return ;
}
// Markdown heuristic first — hljs often fails to detect it
if ( _looksLikeMarkdown ( text ) ) {
const langSelect = document . getElementById ( 'doc-language-select' ) ;
if ( langSelect && langSelect . value !== 'markdown' ) {
langSelect . value = 'markdown' ;
doc . language = 'markdown' ;
updateLanguage ( ) ;
syncHighlighting ( ) ;
_syncHeaderActions ( ) ;
const mdToolbar = document . getElementById ( 'doc-md-toolbar' ) ;
if ( mdToolbar ) { mdToolbar . style . display = '' ; if ( mdToolbar . _syncOverflow ) requestAnimationFrame ( mdToolbar . _syncOverflow ) ; }
}
return ;
}
const sample = text . slice ( 0 , AUTO _DETECT _SAMPLE _SIZE ) ;
const result = window . hljs . highlightAuto ( sample ) ;
if ( ! result . language || result . relevance < AUTO _DETECT _MIN _RELEVANCE ) return ;
const mapped = HLJS _TO _DROPDOWN [ result . language ] ;
if ( ! mapped ) return ;
const langSelect = document . getElementById ( 'doc-language-select' ) ;
if ( ! langSelect || langSelect . value === mapped ) return ;
langSelect . value = mapped ;
doc . language = mapped ;
updateLanguage ( ) ;
syncHighlighting ( ) ;
_syncHeaderActions ( ) ;
const mdToolbar2 = document . getElementById ( 'doc-md-toolbar' ) ;
if ( mdToolbar2 ) mdToolbar2 . style . display = ( mapped === 'markdown' ) ? '' : 'none' ;
}
// ---- Selection-based AI editing ----
// Tracked selection state — when set, the next chat message auto-includes this context
let _selections = [ ] ; // [{ text, startLine, endLine, start, end }, ...]
// Pinned-selection overlays are positioned in pixel coords measured
// against the textarea's current size. When the window shrinks (or
// the sidebar collapses, or the panel resizes), the text wraps to
// more rows but the overlay rectangles stay where they were —
// visibly drifting off the real highlighted text. Re-render on any
// size change so the overlays follow the new wrap. Debounced via
// rAF to coalesce the rapid-fire ResizeObserver pulses during a
// drag-resize.
let _selResizeScheduled = false ;
function _scheduleSelRerender ( ) {
if ( _selResizeScheduled || _selections . length === 0 ) return ;
_selResizeScheduled = true ;
requestAnimationFrame ( ( ) => {
_selResizeScheduled = false ;
try { renderAllSelectionHighlights ( ) ; } catch ( _ ) { }
} ) ;
}
if ( typeof window !== 'undefined' ) {
window . addEventListener ( 'resize' , _scheduleSelRerender ) ;
}
// Observe the textarea itself so internal layout changes (sidebar
// collapse, panel snap, mobile keyboard show/hide) also trigger a
// re-render. The observer attaches lazily on first selection so we
// don't churn before the editor mounts.
let _selResizeObserver = null ;
function _ensureSelResizeObserver ( ) {
if ( _selResizeObserver || typeof ResizeObserver === 'undefined' ) return ;
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! ta ) return ;
_selResizeObserver = new ResizeObserver ( _scheduleSelRerender ) ;
_selResizeObserver . observe ( ta ) ;
}
// Detect whether the textarea is currently wrapping any line. If
// every logical line fits on one visual row, the overlay positions
// are exact and pinned selections are safe regardless of fullscreen
// state. We compute rendered-row-count from scrollHeight/line-height
// and compare against the number of \n-separated lines.
function _textareaWraps ( ta ) {
if ( ! ta ) return false ;
const style = getComputedStyle ( ta ) ;
const lh = parseFloat ( style . lineHeight ) || ( parseFloat ( style . fontSize ) * 1.45 ) ;
if ( ! lh ) return false ;
const padTop = parseFloat ( style . paddingTop ) || 0 ;
const padBottom = parseFloat ( style . paddingBottom ) || 0 ;
const renderedRows = Math . round ( ( ta . scrollHeight - padTop - padBottom ) / lh ) ;
const logicalLines = ( ta . value || '' ) . split ( '\n' ) . length ;
return renderedRows > logicalLines ;
}
/ * * U p d a t e s e l e c t i o n t r a c k i n g , s h o w b a d g e + p e r s i s t e n t h i g h l i g h t .
* Each new selection is added ( pinned ) . Click without selecting to clear all . * /
function updateSelectionState ( ) {
// Pinned selections are safe whenever the overlay measurement can
// be exact. That holds in two cases: (1) fullscreen — width is
// stable, or (2) no line wrapping — every logical \n-line fits on
// one visual row, so character-precise mirror measurement isn't
// needed. Outside both cases, panel resizes / wrap shifts make
// overlays drift, so we no-op.
const _pane = document . querySelector ( '.doc-editor-pane' ) ;
const _isFs = ! ! ( _pane && _pane . classList . contains ( 'doc-fullscreen' ) ) ;
const _ta0 = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! _isFs && _textareaWraps ( _ta0 ) ) {
if ( _selections . length ) clearSelection ( ) ;
return ;
}
_ensureSelResizeObserver ( ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
const start = textarea . selectionStart ;
const end = textarea . selectionEnd ;
if ( start === end ) {
// Simple click — don't clear, user might be clicking into chat
return ;
}
const text = textarea . value ;
const selectedText = text . substring ( start , end ) ;
const startLine = text . substring ( 0 , start ) . split ( '\n' ) . length ;
const endLine = text . substring ( 0 , end ) . split ( '\n' ) . length ;
// Check for overlap with existing selection — replace if overlapping
const overlapIdx = _selections . findIndex ( s =>
( start >= s . start && start <= s . end ) || ( end >= s . start && end <= s . end ) ||
( start <= s . start && end >= s . end )
) ;
const entry = { text : selectedText , startLine , endLine , start , end } ;
if ( overlapIdx >= 0 ) {
_selections [ overlapIdx ] = entry ;
} else {
_selections . push ( entry ) ;
}
showSelectionBadge ( ) ;
renderAllSelectionHighlights ( ) ;
}
/** Show a selection indicator badge with count + clear button */
function showSelectionBadge ( ) {
let badge = document . getElementById ( 'doc-selection-badge' ) ;
if ( ! badge ) {
badge = document . createElement ( 'span' ) ;
badge . id = 'doc-selection-badge' ;
badge . className = 'doc-selection-badge' ;
badge . title = 'Selected regions — type in chat to edit' ;
// Sits directly under the formatting toolbar so it reads as part
// of the toolbar row, not buried in the page header. Falls back
// to the editor header if the toolbar isn't on screen.
const toolbar = document . getElementById ( 'doc-md-toolbar' ) ;
if ( toolbar && toolbar . parentNode ) {
toolbar . insertAdjacentElement ( 'afterend' , badge ) ;
} else {
const header = document . querySelector ( '.doc-editor-header' ) ;
if ( header ) header . insertBefore ( badge , header . firstChild ) ;
}
}
if ( _selections . length === 0 ) {
badge . style . display = 'none' ;
return ;
}
const labels = _selections . map ( s =>
s . startLine === s . endLine ? ` L ${ s . startLine } ` : ` L ${ s . startLine } - ${ s . endLine } `
) ;
const label = _selections . length === 1
? ` ${ labels [ 0 ] } selected `
: ` ${ _selections . length } selections ( ${ labels . join ( ', ' ) } ) ` ;
badge . innerHTML = ` ${ label } <button class="doc-selection-clear" title="Clear all selections">×</button> ` ;
badge . style . display = '' ;
badge . querySelector ( '.doc-selection-clear' ) . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
clearSelection ( ) ;
} ) ;
}
/** Markdown / prose docs get character - precise highlights ( like a
* normal browser selection but persistent ) . Code docs get line - based
* highlights — when working in code you usually operate on whole
* lines , and the character - based version reads as jittery against
* monospace alignment . * /
function _isCodeDoc ( ) {
const lang = ( document . getElementById ( 'doc-language-select' ) ? . value || '' ) . toLowerCase ( ) ;
if ( ! lang ) return false ;
// Prose / preview types that should get character-precise highlights.
const prose = new Set ( [ 'markdown' , 'md' , 'text' , 'txt' , 'email' , 'html' , 'csv' ] ) ;
return ! prose . has ( lang ) ;
}
/ * * M e a s u r e t h e v i s u a l x , y p o s i t i o n o f a c h a r a c t e r i n d e x i n s i d e t h e
* mirror element by inserting a zero - width marker span there and
* reading its bounding rect . Returns { x , y } relative to the mirror ' s
* content - box origin . * /
function _measurePos ( mirror , text , pos ) {
mirror . innerHTML = '' ;
if ( pos > 0 ) mirror . appendChild ( document . createTextNode ( text . substring ( 0 , pos ) ) ) ;
const marker = document . createElement ( 'span' ) ;
marker . textContent = ' ' ;
mirror . appendChild ( marker ) ;
const r = marker . getBoundingClientRect ( ) ;
const m = mirror . getBoundingClientRect ( ) ;
return { x : r . left - m . left , y : r . top - m . top } ;
}
/** Render persistent highlight overlays for all selections */
// Re-anchor pinned selections against the live textarea content. After
// an undo or any other path that shrinks/shifts the text, captured
// {start, end} positions can point at unrelated content (or past the
// end of the buffer). We:
// 1. Verify the captured text still sits at [start, end].
// 2. If not, look for the captured text elsewhere in the doc and
// re-anchor. Prefers the nearest occurrence to the old position.
// 3. If the captured text is gone entirely, drop the selection.
// 4. Refresh derived fields (startLine/endLine) when re-anchored.
// Cheap O(N) per selection; only runs when _selections is non-empty.
function _validateSelections ( text ) {
if ( _selections . length === 0 ) return ;
const survivors = [ ] ;
for ( const s of _selections ) {
const captured = s . text || '' ;
if ( ! captured ) continue ;
// Fast path: still at the same offsets.
if ( text . substring ( s . start , s . end ) === captured ) {
survivors . push ( s ) ;
continue ;
}
// Re-anchor: find the captured text and pick the occurrence
// nearest to the old start so multi-match docs don't snap to the
// wrong one. indexOf scans are cheap for typical doc sizes.
let best = - 1 , bestDist = Infinity ;
let from = 0 ;
while ( true ) {
const idx = text . indexOf ( captured , from ) ;
if ( idx === - 1 ) break ;
const dist = Math . abs ( idx - s . start ) ;
if ( dist < bestDist ) { best = idx ; bestDist = dist ; }
from = idx + 1 ;
}
if ( best === - 1 ) continue ; // text gone entirely → drop
const newStart = best ;
const newEnd = best + captured . length ;
survivors . push ( {
... s ,
start : newStart ,
end : newEnd ,
startLine : text . substring ( 0 , newStart ) . split ( '\n' ) . length ,
endLine : text . substring ( 0 , newEnd ) . split ( '\n' ) . length ,
} ) ;
}
_selections = survivors ;
}
function renderAllSelectionHighlights ( ) {
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( ! wrap ) return ;
// Remove old overlays
wrap . querySelectorAll ( '.doc-selection-overlay' ) . forEach ( el => el . remove ( ) ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea || _selections . length === 0 ) return ;
const text = textarea . value ;
// Pre-render guard: re-anchor or drop selections whose text has
// shifted (undo, programmatic edits, etc.) so the overlays never
// draw on the wrong region.
_validateSelections ( text ) ;
if ( _selections . length === 0 ) return ;
const style = getComputedStyle ( textarea ) ;
const paddingTop = parseFloat ( style . paddingTop ) || 10 ;
const paddingLeft = parseFloat ( style . paddingLeft ) || 48 ;
const lineHeight = parseFloat ( style . lineHeight ) || ( parseFloat ( style . fontSize ) * 1.45 ) ;
// Shared mirror for measurement — same box model as the textarea
// so any measurement we take lines up 1:1 with the rendered text.
let mirror = document . getElementById ( 'doc-selection-mirror' ) ;
if ( ! mirror ) {
mirror = document . createElement ( 'div' ) ;
mirror . id = 'doc-selection-mirror' ;
// box-sizing:border-box is critical — without it the mirror's
// actual box width = (width prop) + horizontal padding, which is
// wider than the textarea's text-render area. Text wraps at a
// different column inside the mirror, so every measured y-offset
// drifts from where the real text sits. border-box makes
// mirror.box = textarea.clientWidth exactly.
mirror . style . cssText = 'position:absolute;top:0;left:0;right:0;visibility:hidden;pointer-events:none;' +
'white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;overflow:hidden;box-sizing:border-box;' ;
wrap . appendChild ( mirror ) ;
}
mirror . style . font = style . font ;
mirror . style . padding = style . padding ;
mirror . style . borderWidth = style . borderWidth ;
mirror . style . borderStyle = 'solid' ;
mirror . style . borderColor = 'transparent' ;
mirror . style . width = textarea . clientWidth + 'px' ;
mirror . style . tabSize = style . tabSize ;
mirror . style . letterSpacing = style . letterSpacing ;
mirror . style . wordSpacing = style . wordSpacing ;
mirror . style . textIndent = style . textIndent ;
const codeDoc = _isCodeDoc ( ) ;
const scrollTop = textarea . scrollTop ;
for ( const sel of _selections ) {
if ( codeDoc ) {
// Line-based: span every line that contains any selected char.
const beforeStart = text . substring ( 0 , sel . start ) ;
const lastNewline = beforeStart . lastIndexOf ( '\n' ) ;
const startLineBegin = lastNewline + 1 ;
mirror . textContent = text . substring ( 0 , startLineBegin ) ;
const startTop = mirror . scrollHeight - paddingTop ;
const afterEnd = text . indexOf ( '\n' , sel . end ) ;
const endLineEnd = afterEnd === - 1 ? text . length : afterEnd ;
mirror . textContent = text . substring ( 0 , endLineEnd ) ;
const endBottom = mirror . scrollHeight - paddingTop ;
mirror . textContent = '' ;
const top = paddingTop + startTop - scrollTop ;
const height = endBottom - startTop || lineHeight ;
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'doc-selection-overlay' ;
overlay . style . top = top + 'px' ;
overlay . style . left = paddingLeft + 'px' ;
overlay . style . right = '0' ;
overlay . style . height = height + 'px' ;
wrap . appendChild ( overlay ) ;
} else {
// Character-precise: measure the actual selection start/end via
// a marker span. Render one rect for single-line selections, or
// three rects (first partial, middle full, last partial) for
// multi-line selections.
const startPos = _measurePos ( mirror , text , sel . start ) ;
const endPos = _measurePos ( mirror , text , sel . end ) ;
mirror . innerHTML = '' ;
const addRect = ( top , left , width , height ) => {
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'doc-selection-overlay' ;
overlay . style . top = ( paddingTop + top - scrollTop ) + 'px' ;
overlay . style . left = ( paddingLeft + left ) + 'px' ;
if ( width != null ) overlay . style . width = width + 'px' ;
else overlay . style . right = '0' ;
overlay . style . height = height + 'px' ;
wrap . appendChild ( overlay ) ;
} ;
if ( Math . abs ( endPos . y - startPos . y ) < 1 ) {
// Single visual line.
addRect ( startPos . y , startPos . x , endPos . x - startPos . x , lineHeight ) ;
} else {
// First line: from selection start to right edge.
addRect ( startPos . y , startPos . x , null , lineHeight ) ;
// Middle lines (if any): full-width band between the two.
const middleTop = startPos . y + lineHeight ;
const middleHeight = endPos . y - middleTop ;
if ( middleHeight > 0 ) addRect ( middleTop , 0 , null , middleHeight ) ;
// Last line: from left edge to selection end.
addRect ( endPos . y , 0 , endPos . x , lineHeight ) ;
}
}
}
}
/** Sync all selection highlight positions on scroll */
function syncSelectionOverlay ( ) {
if ( _selections . length === 0 ) return ;
renderAllSelectionHighlights ( ) ;
}
/** Clear all selections, badge, and highlights */
function clearSelection ( ) {
_selections = [ ] ;
const badge = document . getElementById ( 'doc-selection-badge' ) ;
if ( badge ) badge . style . display = 'none' ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( wrap ) wrap . querySelectorAll ( '.doc-selection-overlay' ) . forEach ( el => el . remove ( ) ) ;
}
/ * *
* Get all selection contexts for chat injection .
* Called by chat module before sending a message .
* Returns null if no selections , or array of { text , startLine , endLine } .
* /
export function getSelectionContext ( ) {
if ( _selections . length === 0 ) return null ;
// Re-anchor / drop stale selections before handing them to chat —
// shipping text from a stale offset would mean the AI sees content
// from a different region than what the user thinks they highlighted.
const _ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( _ta ) _validateSelections ( _ta . value ) ;
if ( _selections . length === 0 ) return null ;
if ( _selections . length === 1 ) {
const ctx = _selections [ 0 ] ;
clearSelection ( ) ;
return ctx ;
}
// Multiple selections — return array
const ctx = [ ... _selections ] ;
clearSelection ( ) ;
return ctx ;
}
// ── Inline Suggestion Comments (Google Docs style) ──
let _activeSuggestions = [ ] ; // [{ id, find, replace, reason, highlightEl, bubbleEl }]
/** Persist suggestions to localStorage for the active doc */
function _saveSuggestionsToStorage ( ) {
if ( ! activeDocId ) return ;
const data = _activeSuggestions . map ( s => ( { id : s . id , find : s . find , replace : s . replace , reason : s . reason } ) ) ;
if ( data . length ) {
localStorage . setItem ( 'odysseus-suggestions-' + activeDocId , JSON . stringify ( data ) ) ;
} else {
localStorage . removeItem ( 'odysseus-suggestions-' + activeDocId ) ;
}
}
/** Restore suggestions from localStorage for a doc */
function _restoreSuggestionsFromStorage ( docId ) {
try {
const raw = localStorage . getItem ( 'odysseus-suggestions-' + docId ) ;
if ( ! raw ) return ;
const data = JSON . parse ( raw ) ;
if ( ! Array . isArray ( data ) || ! data . length ) return ;
_activeSuggestions = data . map ( s => ( { id : s . id , find : s . find , replace : s . replace , reason : s . reason , cardEl : null } ) ) ;
_suggestionTotal = _activeSuggestions . length ;
_suggestionIndex = 0 ;
_showCurrentSuggestion ( ) ;
} catch { }
}
/ * * H a n d l e d o c _ s u g g e s t i o n s S S E e v e n t — s h o w o n e s u g g e s t i o n a t a t i m e .
*
* If a previous batch is already pending approval , NEW suggestions are
* appended to the live queue rather than replacing it . The agent ( or a
* follow - up batch ) can keep adding edits while the user reviews ; the count
* and "n of m" header update on the fly . * /
export function handleDocSuggestions ( data ) {
if ( _diffModeActive ) exitDiffMode ( true ) ;
if ( ! data . suggestions || ! data . suggestions . length ) return ;
if ( ! isOpen ) openPanel ( ) ;
if ( data . doc _id && data . doc _id !== activeDocId ) switchToDoc ( data . doc _id ) ;
const hadPending = _activeSuggestions . length > 0 ;
const existingIds = new Set ( _activeSuggestions . map ( s => s . id ) ) ;
// Append new suggestions, skipping any IDs already in the queue so a
// re-sent batch doesn't duplicate.
let added = 0 ;
for ( const sugg of data . suggestions ) {
if ( existingIds . has ( sugg . id ) ) continue ;
_activeSuggestions . push ( {
id : sugg . id ,
find : sugg . find ,
replace : sugg . replace ,
reason : sugg . reason ,
cardEl : null ,
} ) ;
added ++ ;
}
_suggestionTotal = ( _suggestionTotal || 0 ) + added ;
_saveSuggestionsToStorage ( ) ;
// If nothing was pending before, kick off the visual flow. Otherwise the
// currently-shown suggestion stays on screen and the queue size update is
// reflected in the next card's header.
if ( ! hadPending ) {
_suggestionIndex = 0 ;
_showCurrentSuggestion ( ) ;
} else {
// Refresh just the counter in the active card so the user sees the
// queue grew while they were deliberating.
const active = document . getElementById ( 'doc-suggestion-active' ) ;
if ( active ) {
const counter = active . querySelector ( '.doc-suggestion-counter' ) ;
if ( counter ) {
const num = _suggestionTotal - _activeSuggestions . length + 1 ;
counter . textContent = ` ${ num } / ${ _suggestionTotal } ` ;
}
}
}
}
/** Render the current suggestion card (one at a time) + inline diff in document */
function _showCurrentSuggestion ( ) {
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
const pane = document . querySelector ( '.doc-editor-pane' ) ;
if ( ! wrap || ! pane ) return ;
// Remove previous card and inline diff
const old = document . getElementById ( 'doc-suggestion-active' ) ;
if ( old ) { if ( old . _cleanup ) old . _cleanup ( ) ; old . remove ( ) ; }
_clearSuggestionHighlight ( ) ;
_clearInlineDiff ( ) ;
if ( _activeSuggestions . length === 0 ) {
return ;
}
const sugg = _activeSuggestions [ 0 ] ;
const remaining = _activeSuggestions . length ;
const num = _suggestionTotal - remaining + 1 ;
// Show inline diff in the document
_showInlineDiff ( sugg . find , sugg . replace ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
// Scroll to the change text
if ( textarea ) {
const text = textarea . value ;
const idx = text . indexOf ( sugg . find ) ;
if ( idx >= 0 ) {
const lineNum = text . substring ( 0 , idx ) . split ( '\n' ) . length - 1 ;
const lineH = parseFloat ( getComputedStyle ( textarea ) . lineHeight ) || 20 ;
const target = Math . max ( 0 , lineNum * lineH - ( textarea . clientHeight / 3 ) ) ;
textarea . scrollTop = target ;
}
}
// Position card next to the highlighted text
function _positionCard ( card ) {
if ( ! textarea ) return ;
const text = textarea . value ;
const idx = text . indexOf ( sugg . find ) ;
if ( idx < 0 ) return ;
const linesBefore = text . substring ( 0 , idx ) . split ( '\n' ) . length - 1 ;
const lineH = parseFloat ( getComputedStyle ( textarea ) . lineHeight ) || 20 ;
const textareaRect = textarea . getBoundingClientRect ( ) ;
const paddingTop = parseFloat ( getComputedStyle ( textarea ) . paddingTop ) || 10 ;
const rawTop = textareaRect . top + paddingTop + ( linesBefore * lineH ) - textarea . scrollTop ;
const clampedTop = Math . max ( 60 , Math . min ( rawTop , window . innerHeight - 220 ) ) ;
card . style . position = 'fixed' ;
card . style . top = clampedTop + 'px' ;
const paneRect = pane . getBoundingClientRect ( ) ;
const isMobile = window . innerWidth <= 768 ;
if ( ! isMobile ) {
if ( paneRect . right + 270 < window . innerWidth ) {
card . style . left = ( paneRect . right + 16 ) + 'px' ;
card . style . right = '' ;
} else {
card . style . left = '' ;
card . style . right = ( window . innerWidth - paneRect . left + 16 ) + 'px' ;
}
}
// Also position the highlight overlay
_clearSuggestionHighlight ( ) ;
_highlightSuggestionText ( sugg . find ) ;
}
// Build the card
const card = document . createElement ( 'div' ) ;
card . id = 'doc-suggestion-active' ;
card . className = 'doc-suggestion-card' ;
card . innerHTML = `
< div class = "doc-suggestion-header" >
< div class = "doc-suggestion-nav" >
< button class = "doc-suggestion-nav-btn doc-suggestion-prev" title = "Previous" > & lsaquo ; < / b u t t o n >
< span class = "doc-suggestion-counter" > $ { num } / $ { _suggestionTotal } < / s p a n >
< button class = "doc-suggestion-nav-btn doc-suggestion-next" title = "Next" > & rsaquo ; < / b u t t o n >
< / d i v >
< button class = "doc-suggestion-close" title = "Close all suggestions" > & times ; < / b u t t o n >
< / d i v >
< div class = "doc-suggestion-reason" > $ { _esc ( sugg . reason ) } < / d i v >
< div class = "doc-suggestion-actions" >
< button class = "doc-suggestion-accept" > Accept < / b u t t o n >
< button class = "doc-suggestion-dismiss" > Skip < / b u t t o n >
$ { remaining > 1 ? '<button class="doc-suggestion-accept-all">Accept All</button>' : '' }
< / d i v >
` ;
// Wire buttons
card . querySelector ( '.doc-suggestion-close' ) . addEventListener ( 'click' , clearAllSuggestions ) ;
card . querySelector ( '.doc-suggestion-prev' ) . addEventListener ( 'click' , ( ) => {
const current = _activeSuggestions . shift ( ) ;
_activeSuggestions . push ( current ) ;
const prev = _activeSuggestions . pop ( ) ;
_activeSuggestions . unshift ( prev ) ;
_suggestionIndex = ( _suggestionIndex - 1 + _suggestionTotal ) % _suggestionTotal ;
_showCurrentSuggestion ( ) ;
} ) ;
card . querySelector ( '.doc-suggestion-next' ) . addEventListener ( 'click' , ( ) => {
const current = _activeSuggestions . shift ( ) ;
_activeSuggestions . push ( current ) ;
_suggestionIndex = ( _suggestionIndex + 1 ) % _suggestionTotal ;
_showCurrentSuggestion ( ) ;
} ) ;
card . querySelector ( '.doc-suggestion-accept' ) . addEventListener ( 'click' , ( ) => {
_applySuggestion ( sugg ) ;
_activeSuggestions . shift ( ) ;
_animateNext ( ) ;
} ) ;
card . querySelector ( '.doc-suggestion-dismiss' ) . addEventListener ( 'click' , ( ) => {
_activeSuggestions . shift ( ) ;
_animateNext ( ) ;
} ) ;
const acceptAllBtn = card . querySelector ( '.doc-suggestion-accept-all' ) ;
if ( acceptAllBtn ) {
acceptAllBtn . addEventListener ( 'click' , ( ) => {
for ( const s of _activeSuggestions ) _applySuggestion ( s ) ;
_activeSuggestions = [ ] ;
_animateNext ( ) ;
} ) ;
}
sugg . cardEl = card ;
document . body . appendChild ( card ) ;
// Position after a tick so scroll has taken effect
requestAnimationFrame ( ( ) => _positionCard ( card ) ) ;
// Reposition on scroll/resize so the card stays anchored
const _reposition = ( ) => { if ( card . isConnected ) _positionCard ( card ) ; } ;
if ( textarea ) textarea . addEventListener ( 'scroll' , _reposition ) ;
window . addEventListener ( 'resize' , _reposition ) ;
// Store cleanup refs on the card
card . _cleanup = ( ) => {
if ( textarea ) textarea . removeEventListener ( 'scroll' , _reposition ) ;
window . removeEventListener ( 'resize' , _reposition ) ;
} ;
}
/** Show inline diff by modifying the code highlight element directly */
function _showInlineDiff ( findText , replaceText ) {
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! codeEl || ! textarea ) return ;
const text = textarea . value ;
const idx = text . indexOf ( findText ) ;
if ( idx === - 1 ) return ;
const before = text . substring ( 0 , idx ) ;
const after = text . substring ( idx + findText . length ) ;
// Character-level diff
let cPre = 0 ;
while ( cPre < findText . length && cPre < replaceText . length && findText [ cPre ] === replaceText [ cPre ] ) cPre ++ ;
let cSuf = 0 ;
while ( cSuf < ( findText . length - cPre ) && cSuf < ( replaceText . length - cPre ) &&
findText [ findText . length - 1 - cSuf ] === replaceText [ replaceText . length - 1 - cSuf ] ) cSuf ++ ;
const commonBefore = findText . substring ( 0 , cPre ) ;
const commonAfter = findText . substring ( findText . length - cSuf ) ;
const delPart = findText . substring ( cPre , findText . length - cSuf ) ;
const addPart = replaceText . substring ( cPre , replaceText . length - cSuf ) ;
// Replace codeEl content with diff-marked version
codeEl . innerHTML = '' ;
codeEl . appendChild ( document . createTextNode ( before ) ) ;
if ( commonBefore ) codeEl . appendChild ( document . createTextNode ( commonBefore ) ) ;
if ( delPart ) {
const del = document . createElement ( 'span' ) ;
del . className = 'sugg-inline-del' ;
del . textContent = delPart ;
codeEl . appendChild ( del ) ;
}
if ( addPart ) {
const add = document . createElement ( 'span' ) ;
add . className = 'sugg-inline-add' ;
add . textContent = addPart ;
codeEl . appendChild ( add ) ;
}
if ( commonAfter ) codeEl . appendChild ( document . createTextNode ( commonAfter ) ) ;
codeEl . appendChild ( document . createTextNode ( after + '\n' ) ) ;
// Mark that we have an active diff so syncHighlighting doesn't overwrite it
codeEl . dataset . hasDiff = '1' ;
}
/** Clear inline diff — restore normal highlighting */
function _clearInlineDiff ( ) {
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( codeEl && codeEl . dataset . hasDiff ) {
delete codeEl . dataset . hasDiff ;
syncHighlighting ( ) ;
}
}
// ---- Diff mode (line-level review) ----
const DIFF _MODE _THRESHOLD = 3 ; // min changed lines to trigger diff mode
/** Line-level LCS diff algorithm */
function _computeLineDiff ( oldText , newText ) {
const oldLines = oldText . split ( '\n' ) ;
const newLines = newText . split ( '\n' ) ;
const m = oldLines . length , n = newLines . length ;
// Build LCS table
const dp = Array . from ( { length : m + 1 } , ( ) => new Uint16Array ( n + 1 ) ) ;
for ( let i = 1 ; i <= m ; i ++ ) {
for ( let j = 1 ; j <= n ; j ++ ) {
dp [ i ] [ j ] = oldLines [ i - 1 ] === newLines [ j - 1 ]
? dp [ i - 1 ] [ j - 1 ] + 1
: Math . max ( dp [ i - 1 ] [ j ] , dp [ i ] [ j - 1 ] ) ;
}
}
// Backtrack to produce diff entries
const entries = [ ] ;
let i = m , j = n ;
while ( i > 0 || j > 0 ) {
if ( i > 0 && j > 0 && oldLines [ i - 1 ] === newLines [ j - 1 ] ) {
entries . push ( { type : 'equal' , line : oldLines [ i - 1 ] } ) ;
i -- ; j -- ;
} else if ( j > 0 && ( i === 0 || dp [ i ] [ j - 1 ] >= dp [ i - 1 ] [ j ] ) ) {
entries . push ( { type : 'insert' , line : newLines [ j - 1 ] } ) ;
j -- ;
} else {
entries . push ( { type : 'delete' , line : oldLines [ i - 1 ] } ) ;
i -- ;
}
}
entries . reverse ( ) ;
return entries ;
}
/** Group diff entries into chunks (contiguous change blocks) */
function _buildDiffChunks ( entries ) {
const chunks = [ ] ;
let chunkId = 0 ;
let lineIdx = 0 ;
let i = 0 ;
while ( i < entries . length ) {
const e = entries [ i ] ;
if ( e . type === 'equal' ) {
lineIdx ++ ;
i ++ ;
} else {
// Gather contiguous non-equal entries into a chunk
const startLine = lineIdx ;
const oldLines = [ ] , newLines = [ ] ;
while ( i < entries . length && entries [ i ] . type !== 'equal' ) {
if ( entries [ i ] . type === 'delete' ) oldLines . push ( entries [ i ] . line ) ;
else newLines . push ( entries [ i ] . line ) ;
i ++ ;
}
chunks . push ( {
id : chunkId ++ ,
oldLines ,
newLines ,
startLine ,
resolved : false ,
accepted : false ,
} ) ;
lineIdx += oldLines . length + newLines . length ;
}
}
return chunks ;
}
/** Enter diff mode — show line-level diff for review */
function enterDiffMode ( oldContent , newContent ) {
if ( _diffModeActive ) exitDiffMode ( true ) ;
_diffModeActive = true ;
_diffOldContent = oldContent ;
_diffNewContent = newContent ;
const entries = _computeLineDiff ( oldContent , newContent ) ;
_diffChunks = _buildDiffChunks ( entries ) ;
_diffUnresolvedCount = _diffChunks . length ;
if ( _diffChunks . length === 0 ) {
_diffModeActive = false ;
if ( uiModule ) uiModule . showToast ( 'No changes' ) ;
return ;
}
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) textarea . readOnly = true ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( wrap ) wrap . classList . add ( 'diff-mode' ) ;
_renderDiffOverlay ( entries ) ;
_renderDiffToolbar ( ) ;
_renderDiffGutter ( ) ;
// Update header button
const diffBtn = document . getElementById ( 'doc-diff-toggle-btn' ) ;
if ( diffBtn ) diffBtn . classList . add ( 'active' ) ;
}
/** Render the line-level diff into the code highlight element */
function _renderDiffOverlay ( entries ) {
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
const gutter = document . getElementById ( 'doc-line-numbers' ) ;
if ( ! codeEl ) return ;
codeEl . innerHTML = '' ;
let gutterHtml = '' ;
let oldNum = 0 , newNum = 0 ;
// Pre-assign chunk IDs to entries by walking chunks and entries together
let chunkIdx = 0 ;
let entryIdx = 0 ;
const entryChunkMap = new Array ( entries . length ) . fill ( - 1 ) ;
while ( entryIdx < entries . length ) {
if ( entries [ entryIdx ] . type === 'equal' ) {
entryIdx ++ ;
} else {
// This is the start of a change block — assign all contiguous non-equal entries to the current chunk
const cid = chunkIdx < _diffChunks . length ? _diffChunks [ chunkIdx ] . id : - 1 ;
while ( entryIdx < entries . length && entries [ entryIdx ] . type !== 'equal' ) {
entryChunkMap [ entryIdx ] = cid ;
entryIdx ++ ;
}
chunkIdx ++ ;
}
}
for ( let i = 0 ; i < entries . length ; i ++ ) {
const e = entries [ i ] ;
if ( e . type === 'equal' ) {
oldNum ++ ; newNum ++ ;
const el = document . createElement ( 'span' ) ;
el . className = 'diff-line-equal' ;
el . textContent = e . line + '\n' ;
codeEl . appendChild ( el ) ;
gutterHtml += newNum + '\n' ;
} else if ( e . type === 'delete' ) {
oldNum ++ ;
const el = document . createElement ( 'span' ) ;
el . className = 'diff-line-del' ;
if ( entryChunkMap [ i ] >= 0 ) el . dataset . chunkId = entryChunkMap [ i ] ;
el . textContent = e . line + '\n' ;
codeEl . appendChild ( el ) ;
gutterHtml += '− \n' ;
} else {
newNum ++ ;
const el = document . createElement ( 'span' ) ;
el . className = 'diff-line-add' ;
if ( entryChunkMap [ i ] >= 0 ) el . dataset . chunkId = entryChunkMap [ i ] ;
el . textContent = e . line + '\n' ;
codeEl . appendChild ( el ) ;
gutterHtml += '+\n' ;
}
}
if ( gutter ) gutter . textContent = gutterHtml ;
codeEl . dataset . hasDiff = '1' ;
// Sync textarea to show the combined view (old + new interleaved) for scroll sizing
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) {
const allLines = entries . map ( e => e . line ) ;
textarea . value = allLines . join ( '\n' ) + '\n' ;
}
}
/** Render the diff toolbar above the editor */
function _renderDiffToolbar ( ) {
let toolbar = document . getElementById ( 'doc-diff-toolbar' ) ;
if ( toolbar ) toolbar . remove ( ) ;
toolbar = document . createElement ( 'div' ) ;
toolbar . id = 'doc-diff-toolbar' ;
toolbar . className = 'diff-toolbar' ;
const status = document . createElement ( 'span' ) ;
status . className = 'diff-toolbar-status' ;
status . id = 'diff-toolbar-status' ;
_updateDiffStatus ( status ) ;
const acceptAll = document . createElement ( 'button' ) ;
acceptAll . className = 'diff-toolbar-btn diff-toolbar-btn-accept' ;
acceptAll . textContent = 'Accept All' ;
acceptAll . addEventListener ( 'click' , ( ) => _resolveAllChunks ( true ) ) ;
const rejectAll = document . createElement ( 'button' ) ;
rejectAll . className = 'diff-toolbar-btn diff-toolbar-btn-reject' ;
rejectAll . textContent = 'Reject All' ;
rejectAll . addEventListener ( 'click' , ( ) => _resolveAllChunks ( false ) ) ;
toolbar . appendChild ( status ) ;
toolbar . appendChild ( acceptAll ) ;
toolbar . appendChild ( rejectAll ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( wrap ) wrap . parentNode . insertBefore ( toolbar , wrap ) ;
}
/** Render per-chunk accept/reject buttons in a gutter overlay */
function _renderDiffGutter ( ) {
let gutterEl = document . getElementById ( 'doc-diff-gutter' ) ;
if ( gutterEl ) gutterEl . remove ( ) ;
gutterEl = document . createElement ( 'div' ) ;
gutterEl . id = 'doc-diff-gutter' ;
gutterEl . className = 'diff-gutter' ;
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( ! codeEl ) return ;
// Insert chunk action buttons directly next to the first changed line of each chunk
// This way they scroll naturally with the content
requestAnimationFrame ( ( ) => {
for ( const chunk of _diffChunks ) {
if ( chunk . resolved ) continue ;
const firstEl = codeEl . querySelector ( ` [data-chunk-id=" ${ chunk . id } "] ` ) ;
if ( ! firstEl ) continue ;
const actions = document . createElement ( 'span' ) ;
actions . className = 'diff-chunk-actions' ;
actions . dataset . chunkId = chunk . id ;
const acceptBtn = document . createElement ( 'button' ) ;
acceptBtn . className = 'diff-chunk-btn diff-chunk-btn-accept' ;
acceptBtn . title = 'Accept change' ;
acceptBtn . innerHTML = '✓' ;
acceptBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _resolveChunk ( chunk . id , true ) ; } ) ;
const rejectBtn = document . createElement ( 'button' ) ;
rejectBtn . className = 'diff-chunk-btn diff-chunk-btn-reject' ;
rejectBtn . title = 'Reject change' ;
rejectBtn . innerHTML = '✗' ;
rejectBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _resolveChunk ( chunk . id , false ) ; } ) ;
actions . appendChild ( acceptBtn ) ;
actions . appendChild ( rejectBtn ) ;
// Insert at the start of the first line span
firstEl . style . position = 'relative' ;
firstEl . appendChild ( actions ) ;
}
} ) ;
}
/** Update the toolbar status text */
function _updateDiffStatus ( statusEl ) {
const el = statusEl || document . getElementById ( 'diff-toolbar-status' ) ;
if ( ! el ) return ;
const resolved = _diffChunks . length - _diffUnresolvedCount ;
el . textContent = ` ${ resolved } / ${ _diffChunks . length } changes resolved ` ;
}
/** Resolve a single chunk */
function _resolveChunk ( chunkId , accept ) {
const chunk = _diffChunks . find ( c => c . id === chunkId ) ;
if ( ! chunk || chunk . resolved ) return ;
chunk . resolved = true ;
chunk . accepted = accept ;
_diffUnresolvedCount -- ;
// Fade resolved lines in the overlay
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( codeEl ) {
codeEl . querySelectorAll ( ` [data-chunk-id=" ${ chunkId } "] ` ) . forEach ( el => {
el . classList . add ( 'diff-chunk-resolved' ) ;
} ) ;
}
// Remove the gutter buttons for this chunk
const gutterActions = document . querySelector ( ` .diff-chunk-actions[data-chunk-id=" ${ chunkId } "] ` ) ;
if ( gutterActions ) gutterActions . remove ( ) ;
_updateDiffStatus ( ) ;
// Persist partial progress so refresh doesn't lose individually-resolved chunks
_applyResolvedChunksToTextarea ( ) ;
saveDocument ( { silent : true } ) ;
if ( _diffUnresolvedCount === 0 ) {
setTimeout ( ( ) => exitDiffMode ( false ) , 300 ) ;
}
}
/ * * C o m p u t e c u r r e n t c o n t e n t f r o m o l d + r e s o l v e d c h u n k d e c i s i o n s ; u n r e s o l v e d c h u n k s
* default to the original ( rejected ) until the user decides . Updates textarea . * /
function _applyResolvedChunksToTextarea ( ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
const entries = _computeLineDiff ( _diffOldContent || '' , _diffNewContent || '' ) ;
const result = [ ] ;
let chunkIdx = 0 ;
let i = 0 ;
while ( i < entries . length ) {
if ( entries [ i ] . type === 'equal' ) {
result . push ( entries [ i ] . line ) ;
i ++ ;
} else {
const chunk = _diffChunks [ chunkIdx ++ ] ;
const chunkOld = [ ] , chunkNew = [ ] ;
while ( i < entries . length && entries [ i ] . type !== 'equal' ) {
if ( entries [ i ] . type === 'delete' ) chunkOld . push ( entries [ i ] . line ) ;
else chunkNew . push ( entries [ i ] . line ) ;
i ++ ;
}
// Resolved+accepted → use new; resolved+rejected OR unresolved → keep old
if ( chunk && chunk . resolved && chunk . accepted ) {
result . push ( ... chunkNew ) ;
} else {
result . push ( ... chunkOld ) ;
}
}
}
textarea . value = result . join ( '\n' ) ;
}
/** Resolve all chunks at once */
function _resolveAllChunks ( accept ) {
for ( const chunk of _diffChunks ) {
if ( ! chunk . resolved ) {
chunk . resolved = true ;
chunk . accepted = accept ;
}
}
_diffUnresolvedCount = 0 ;
exitDiffMode ( false ) ;
}
/** Exit diff mode and apply resolved changes */
function exitDiffMode ( discard ) {
if ( ! _diffModeActive ) return ;
_diffModeActive = false ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( wrap ) wrap . classList . remove ( 'diff-mode' ) ;
if ( discard ) {
// Reject all — restore original content
if ( textarea ) textarea . value = _diffOldContent || '' ;
} else {
// Build final content from resolved chunks
const oldLines = ( _diffOldContent || '' ) . split ( '\n' ) ;
const newLines = ( _diffNewContent || '' ) . split ( '\n' ) ;
const entries = _computeLineDiff ( _diffOldContent || '' , _diffNewContent || '' ) ;
const result = [ ] ;
let chunkIdx = 0 ;
let i = 0 ;
while ( i < entries . length ) {
if ( entries [ i ] . type === 'equal' ) {
result . push ( entries [ i ] . line ) ;
i ++ ;
} else {
// Find the matching chunk
const chunk = _diffChunks [ chunkIdx ++ ] ;
// Skip all entries belonging to this chunk
const chunkOld = [ ] , chunkNew = [ ] ;
while ( i < entries . length && entries [ i ] . type !== 'equal' ) {
if ( entries [ i ] . type === 'delete' ) chunkOld . push ( entries [ i ] . line ) ;
else chunkNew . push ( entries [ i ] . line ) ;
i ++ ;
}
if ( chunk && chunk . accepted ) {
result . push ( ... chunkNew ) ;
} else {
result . push ( ... chunkOld ) ;
}
}
}
if ( textarea ) textarea . value = result . join ( '\n' ) ;
}
// Restore editor state
if ( textarea ) textarea . readOnly = false ;
if ( codeEl ) delete codeEl . dataset . hasDiff ;
// Clean up toolbar and any remaining chunk action buttons
const toolbar = document . getElementById ( 'doc-diff-toolbar' ) ;
if ( toolbar ) toolbar . remove ( ) ;
document . querySelectorAll ( '.diff-chunk-actions' ) . forEach ( el => el . remove ( ) ) ;
// Reset state
_diffOldContent = null ;
_diffNewContent = null ;
_diffChunks = [ ] ;
_diffUnresolvedCount = 0 ;
const diffBtn = document . getElementById ( 'doc-diff-toggle-btn' ) ;
if ( diffBtn ) diffBtn . classList . remove ( 'active' ) ;
syncHighlighting ( ) ;
updateLineNumbers ( textarea ? textarea . value : '' ) ;
saveDocument ( { silent : true } ) ;
}
/** Check if diff mode is active */
function isDiffModeActive ( ) { return _diffModeActive ; }
let _suggestionTotal = 0 ;
let _suggestionIndex = 0 ;
// Override handleDocSuggestions to track total
const _origHandleDocSuggestions = handleDocSuggestions ;
// (total is set inside handleDocSuggestions before _showCurrentSuggestion)
/** Apply a single suggestion edit without removing from queue */
function _applySuggestion ( sugg ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea && sugg . find && textarea . value . includes ( sugg . find ) ) {
textarea . value = textarea . value . replace ( sugg . find , sugg . replace ) ;
syncHighlighting ( ) ;
saveDocument ( { silent : true } ) ;
}
}
/** Animate transition to next suggestion */
function _animateNext ( ) {
_saveSuggestionsToStorage ( ) ;
const old = document . getElementById ( 'doc-suggestion-active' ) ;
if ( old ) {
if ( old . _cleanup ) old . _cleanup ( ) ;
old . style . transition = 'opacity 0.15s, transform 0.15s' ;
old . style . opacity = '0' ;
old . style . transform = 'translateY(-10px)' ;
setTimeout ( ( ) => {
old . remove ( ) ;
_showCurrentSuggestion ( ) ;
} , 150 ) ;
} else {
_showCurrentSuggestion ( ) ;
}
}
function _esc ( s ) {
return ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ;
}
/** Accept a suggestion — apply the edit */
function acceptSuggestion ( id ) {
const sugg = _activeSuggestions . find ( s => s . id === id ) ;
if ( ! sugg ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea && sugg . find && textarea . value . includes ( sugg . find ) ) {
textarea . value = textarea . value . replace ( sugg . find , sugg . replace ) ;
syncHighlighting ( ) ;
saveDocument ( { silent : true } ) ;
}
// Animate card out
sugg . cardEl . style . transition = 'opacity 0.2s, transform 0.2s' ;
sugg . cardEl . style . opacity = '0' ;
sugg . cardEl . style . transform = 'translateX(10px)' ;
setTimeout ( ( ) => sugg . cardEl . remove ( ) , 200 ) ;
_activeSuggestions = _activeSuggestions . filter ( s => s . id !== id ) ;
_clearSuggestionHighlight ( ) ;
// Remove container if empty
if ( _activeSuggestions . length === 0 ) {
const container = document . getElementById ( 'doc-suggestions-container' ) ;
if ( container ) container . style . display = 'none' ;
}
}
/** Dismiss a suggestion — just remove the card */
function dismissSuggestion ( id ) {
const sugg = _activeSuggestions . find ( s => s . id === id ) ;
if ( ! sugg ) return ;
sugg . cardEl . style . transition = 'opacity 0.15s' ;
sugg . cardEl . style . opacity = '0' ;
setTimeout ( ( ) => sugg . cardEl . remove ( ) , 150 ) ;
_activeSuggestions = _activeSuggestions . filter ( s => s . id !== id ) ;
_clearSuggestionHighlight ( ) ;
if ( _activeSuggestions . length === 0 ) {
const container = document . getElementById ( 'doc-suggestions-container' ) ;
if ( container ) container . style . display = 'none' ;
}
}
/** Clear all suggestion cards */
function clearAllSuggestions ( ) {
_activeSuggestions = [ ] ;
_suggestionTotal = 0 ;
_saveSuggestionsToStorage ( ) ;
_clearSuggestionHighlight ( ) ;
_clearInlineDiff ( ) ;
const old = document . getElementById ( 'doc-suggestion-active' ) ;
if ( old ) { if ( old . _cleanup ) old . _cleanup ( ) ; old . remove ( ) ; }
const container = document . getElementById ( 'doc-suggestions-container' ) ;
if ( container ) { container . innerHTML = '' ; container . style . display = 'none' ; }
// Restore line numbers
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ta ) updateLineNumbers ( ta . value ) ;
}
/** Highlight the referenced text in the editor when hovering a suggestion */
function _highlightSuggestionText ( findText ) {
_clearSuggestionHighlight ( ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( ! textarea || ! wrap ) return ;
const text = textarea . value ;
const idx = text . indexOf ( findText ) ;
if ( idx === - 1 ) return ;
const style = getComputedStyle ( textarea ) ;
const paddingTop = parseFloat ( style . paddingTop ) || 10 ;
const paddingLeft = parseFloat ( style . paddingLeft ) || 48 ;
const lineHeight = parseFloat ( style . lineHeight ) || 20 ;
let mirror = document . getElementById ( 'doc-selection-mirror' ) ;
if ( ! mirror ) return ;
const beforeStart = text . substring ( 0 , idx ) ;
const lastNewline = beforeStart . lastIndexOf ( '\n' ) ;
const startLineBegin = lastNewline + 1 ;
mirror . textContent = text . substring ( 0 , startLineBegin ) ;
const startTop = mirror . scrollHeight - paddingTop ;
const endIdx = idx + findText . length ;
const afterEnd = text . indexOf ( '\n' , endIdx ) ;
const endLineEnd = afterEnd === - 1 ? text . length : afterEnd ;
mirror . textContent = text . substring ( 0 , endLineEnd ) ;
const endBottom = mirror . scrollHeight - paddingTop ;
mirror . textContent = '' ;
const top = paddingTop + startTop - textarea . scrollTop ;
const height = Math . max ( endBottom - startTop , lineHeight ) ;
const highlight = document . createElement ( 'div' ) ;
highlight . className = 'doc-suggestion-highlight' ;
highlight . id = 'doc-suggestion-hover-hl' ;
highlight . style . top = top + 'px' ;
highlight . style . left = paddingLeft + 'px' ;
highlight . style . right = '0' ;
highlight . style . height = height + 'px' ;
wrap . appendChild ( highlight ) ;
// Don't auto-scroll here — caller handles scrolling
}
/** Remove hover highlight */
function _clearSuggestionHighlight ( ) {
const hl = document . getElementById ( 'doc-suggestion-hover-hl' ) ;
if ( hl ) hl . remove ( ) ;
}
/** Run the document's code using the in-browser code runner */
function runDocument ( ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea || ! textarea . value . trim ( ) ) return ;
const code = textarea . value ;
const langSelect = document . getElementById ( 'doc-language-select' ) ;
const lang = ( langSelect ? langSelect . value : '' ) . toLowerCase ( ) ;
// Get or create the output panel below the editor
let outputPanel = document . getElementById ( 'doc-run-output' ) ;
if ( ! outputPanel ) {
outputPanel = document . createElement ( 'div' ) ;
outputPanel . id = 'doc-run-output' ;
outputPanel . className = 'doc-run-output' ;
const editorWrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( editorWrap ) editorWrap . after ( outputPanel ) ;
}
outputPanel . style . display = 'block' ;
outputPanel . innerHTML = '' ;
if ( _isRenderLang ( lang ) ) {
// HTML / SVG / XML — render inline in the sandboxed preview iframe.
outputPanel . style . display = 'none' ;
toggleHtmlPreview ( ) ;
return ;
}
if ( ! codeRunnerModule ) {
outputPanel . innerHTML = '<pre class="doc-run-error">Code runner not loaded</pre>' ;
setTimeout ( ( ) => { if ( outputPanel ) outputPanel . style . display = 'none' ; } , 5000 ) ;
return ;
}
if ( lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh' ) {
codeRunnerModule . runServer ( code , outputPanel , 'bash' ) ;
return ;
}
if ( lang === 'python' || lang === 'py' ) {
codeRunnerModule . runServer ( code , outputPanel , 'python' ) ;
return ;
}
if ( lang === 'javascript' || lang === 'js' ) {
codeRunnerModule . runJavaScript ( code , outputPanel ) ;
return ;
}
outputPanel . innerHTML = '<pre class="doc-run-error">Unsupported language. Supported: bash, python, javascript, html</pre>' ;
setTimeout ( ( ) => { if ( outputPanel ) outputPanel . style . display = 'none' ; } , 5000 ) ;
}
/** Copy document content to clipboard */
async function copyDocument ( ) {
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea || ! textarea . value ) return ;
if ( uiModule && uiModule . copyToClipboard ) {
await uiModule . copyToClipboard ( textarea . value ) ;
} else {
try {
await navigator . clipboard . writeText ( textarea . value ) ;
} catch ( e ) { /* ignore */ }
}
if ( uiModule ) uiModule . showToast ( 'Copied to clipboard' ) ;
}
/* ---- Per-tab context menu ---- */
let _docTabMenu = null ; // singleton dropdown element
function _closeDocTabMenu ( ) {
if ( _docTabMenu ) { _docTabMenu . style . display = 'none' ; }
}
function showDocTabMenu ( btnEl , docId ) {
// Toggle off if already open for this doc
if ( _docTabMenu && _docTabMenu . style . display === 'block' && _docTabMenu . _docId === docId ) {
_closeDocTabMenu ( ) ;
return ;
}
// Capture button position before any DOM changes
const _menuAnchorRect = btnEl . getBoundingClientRect ( ) ;
// Switch to this doc if not already active
if ( docId !== activeDocId ) switchToDoc ( docId ) ;
const doc = docs . get ( docId ) ;
if ( ! doc ) return ;
// Create singleton menu container once
if ( ! _docTabMenu ) {
_docTabMenu = document . createElement ( 'div' ) ;
_docTabMenu . className = 'doc-tab-dropdown' ;
_docTabMenu . style . cssText = 'position:fixed;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:none;' ;
document . body . appendChild ( _docTabMenu ) ;
// Close on outside click
document . addEventListener ( 'click' , ( e ) => {
if ( _docTabMenu && ! _docTabMenu . contains ( e . target ) && ! e . target . closest ( '.doc-tab-menu-btn' ) ) {
_closeDocTabMenu ( ) ;
}
} ) ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key !== 'Escape' || ! _docTabMenu || _docTabMenu . style . display !== 'block' ) return ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
e . stopImmediatePropagation ? . ( ) ;
_closeDocTabMenu ( ) ;
} , true ) ;
}
const lang = ( doc . language || '' ) . toLowerCase ( ) ;
const canRun = _isRenderLang ( lang ) || [ 'javascript' , 'js' , 'python' , 'py' , 'bash' , 'sh' , 'shell' , 'zsh' ] . includes ( lang ) ;
let previewIcon = '' , previewLabel = '' ;
const _mdPreview = document . getElementById ( 'doc-md-preview' ) ;
const _csvPreview = document . getElementById ( 'doc-csv-preview' ) ;
const _htmlPreview = document . getElementById ( 'doc-html-preview' ) ;
const _mdActive = _mdPreview && _mdPreview . style . display !== 'none' ;
const _csvActive = _csvPreview && _csvPreview . style . display !== 'none' ;
const _htmlActive = _htmlPreview && _htmlPreview . style . display !== 'none' ;
if ( lang === 'markdown' ) { previewIcon = 'MD' ; previewLabel = _mdActive ? 'Edit' : 'Preview' ; }
else if ( lang === 'csv' ) { previewIcon = '⊞' ; previewLabel = _csvActive ? 'Edit' : 'Table View' ; }
else if ( _isRenderLang ( lang ) ) { previewIcon = '▶' ; previewLabel = _htmlActive ? 'Edit' : 'Run / Preview' ; }
const _di = ( svg ) => ` <span class="dropdown-icon"> ${ svg } </span> ` ;
const _saveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>' ;
const _copyIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' ;
const _runIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>' ;
const _previewIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>' ;
const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>' ;
let items = '' ;
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="save"> ${ _di ( _saveIco ) } <span>Save</span></div> ` ;
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="copy"> ${ _di ( _copyIco ) } <span>Copy</span></div> ` ;
if ( canRun ) {
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="run"> ${ _di ( _runIco ) } <span>Run</span></div> ` ;
}
if ( previewLabel ) {
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="preview"><span class="dropdown-icon"> ${ previewIcon } </span><span> ${ previewLabel } </span></div> ` ;
}
const _downloadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' ;
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="download"> ${ _di ( _downloadIco ) } <span>Download</span></div> ` ;
// "Send signed reply" — only if this doc was opened from an email attachment
if ( doc . sourceEmailUid && doc . sourceEmailFolder ) {
const _sendBackIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>' ;
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="signed-reply"> ${ _di ( _sendBackIco ) } <span>Send signed reply</span></div> ` ;
}
const _closeIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' ;
items += ` <div class="dropdown-item-compact doc-tab-action" data-action="close"> ${ _di ( _closeIco ) } <span>Close</span></div> ` ;
items += ` <div class="dropdown-divider"></div> ` ;
items += ` <div class="dropdown-item-compact doc-tab-action doc-tab-action-delete" data-action="delete"> ${ _di ( _deleteIco ) } <span>Delete</span></div> ` ;
_docTabMenu . innerHTML = items ;
_docTabMenu . style . display = 'block' ;
_docTabMenu . _docId = docId ;
// Position: anchor to the tab bar bottom, aligned to button horizontally
const rect = _menuAnchorRect ;
const tabBar = document . getElementById ( 'doc-tab-bar' ) ;
const barBottom = tabBar ? tabBar . getBoundingClientRect ( ) . bottom : rect . bottom ;
_docTabMenu . style . position = 'fixed' ;
_docTabMenu . style . zIndex = '1000' ;
_docTabMenu . style . left = rect . left + 'px' ;
_docTabMenu . style . top = ( barBottom + 2 ) + 'px' ;
// Clamp to viewport edges
requestAnimationFrame ( ( ) => {
const menuRect = _docTabMenu . getBoundingClientRect ( ) ;
if ( menuRect . right > window . innerWidth - 8 ) {
_docTabMenu . style . left = ( window . innerWidth - menuRect . width - 8 ) + 'px' ;
}
if ( menuRect . left < 8 ) {
_docTabMenu . style . left = '8px' ;
}
if ( menuRect . bottom > window . innerHeight - 8 ) {
_docTabMenu . style . top = ( barBottom - menuRect . height - 4 ) + 'px' ;
}
} ) ;
// Wire action clicks
_docTabMenu . querySelectorAll ( '.doc-tab-action' ) . forEach ( item => {
item . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const action = item . dataset . action ;
_closeDocTabMenu ( ) ;
switch ( action ) {
case 'save' : saveDocument ( ) ; break ;
case 'copy' : copyDocument ( ) ; break ;
case 'run' : runDocument ( ) ; break ;
case 'preview' :
if ( lang === 'markdown' ) toggleMarkdownPreview ( ) ;
else if ( lang === 'csv' ) toggleCsvPreview ( ) ;
else if ( _isRenderLang ( lang ) ) toggleHtmlPreview ( ) ;
break ;
case 'download' : {
const btn = document . getElementById ( 'doc-fontsize-btn' ) || document . getElementById ( 'doc-language-select' ) ;
showExportMenu ( null , btn ? . getBoundingClientRect ( ) ) ;
break ;
}
case 'signed-reply' : _sendSignedReply ( docId ) ; break ;
case 'close' : closeTab ( docId ) ; break ;
case 'delete' : deleteActiveDocument ( ) ; break ;
}
} ) ;
} ) ;
}
/ * *
* "Send signed reply" — flatten the current PDF ( form fields + signature
* stamps + freeform annotations ) , drop it into the compose - uploads dir ,
* then either :
* 1. add the attachment to an existing open email draft for the same
* source thread ( so multiple signed docs accumulate into one reply ) , or
* 2. create a fresh email - language draft document pre - filled with To /
* Subject / In - Reply - To / References and the first attachment .
* Switches the doc panel to that draft so the user can review + send .
* /
async function _sendSignedReply ( docId ) {
const doc = docs . get ( docId ) ;
if ( ! doc || ! doc . sourceEmailUid ) return ;
if ( uiModule ) uiModule . showToast ( 'Preparing signed reply…' ) ;
let result ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ encodeURIComponent ( docId ) } /prepare-signed-reply ` , {
method : 'POST' ,
credentials : 'same-origin' ,
} ) ;
result = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok || ! result . ok ) {
const msg = ( result && result . error ) || ` HTTP ${ res . status } ` ;
if ( uiModule ) uiModule . showError ( ` Couldn't prepare signed reply: ${ msg } ` ) ;
return ;
}
} catch ( e ) {
console . error ( 'prepare-signed-reply failed:' , e ) ;
if ( uiModule ) uiModule . showError ( "Couldn't prepare signed reply" ) ;
return ;
}
const att = result . attachment ;
const reply = result . reply || { } ;
const mid = reply . source _message _id || doc . sourceEmailMessageId || '' ;
// 1) Already have a draft tab open for this source thread? Append.
for ( const [ , d ] of docs ) {
if ( d . language === 'email' && d . _draftForMessageId === mid && mid ) {
d . _composeAtts = ( d . _composeAtts || [ ] ) . concat ( [ att ] ) ;
await loadDocument ( d . id ) ;
_renderComposeAttachments ( ) ;
if ( uiModule ) uiModule . showToast ( ` Added " ${ att . filename } " to the reply draft ` ) ;
return ;
}
}
// 2) Otherwise create a fresh email draft.
const headerLines = [
` To: ${ reply . to || '' } ` ,
` Subject: ${ reply . subject || '' } ` ,
reply . in _reply _to ? ` In-Reply-To: ${ reply . in _reply _to } ` : null ,
reply . references ? ` References: ${ reply . references } ` : null ,
reply . source _uid ? ` X-Source-UID: ${ reply . source _uid } ` : null ,
reply . source _folder ? ` X-Source-Folder: ${ reply . source _folder } ` : null ,
] . filter ( Boolean ) ;
const content = headerLines . join ( '\n' ) + '\n---\n\nHi' + ( reply . to _name ? ' ' + reply . to _name . split ( /\s+/ ) [ 0 ] : '' ) + ',\n\nPlease find the signed copy attached.\n\nBest,\n' ;
let draftId = null ;
try {
// Use the source PDF's session if available; else fall back to current.
let sessionId = doc . sessionId
|| _lastSessionId
|| ( sessionModule && sessionModule . getCurrentSessionId ( ) ) ;
if ( ! sessionId ) {
try { sessionId = await _autoCreateSession ( ) ; } catch ( _ ) { }
}
const cRes = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'same-origin' ,
body : JSON . stringify ( {
session _id : sessionId ,
title : reply . subject || 'Signed reply' ,
language : 'email' ,
content ,
} ) ,
} ) ;
const created = await cRes . json ( ) ;
draftId = created && ( created . id || created . doc _id ) ;
if ( ! draftId ) throw new Error ( 'No draft id returned' ) ;
} catch ( e ) {
console . error ( 'Failed to create draft doc:' , e ) ;
if ( uiModule ) uiModule . showError ( "Couldn't create reply draft" ) ;
return ;
}
// Tag the draft (in-memory only) with the thread message-id so future
// signed PDFs from the same email get appended to this same draft.
addDocToTabs ( {
id : draftId ,
title : reply . subject || 'Signed reply' ,
language : 'email' ,
current _content : content ,
version _count : 1 ,
} , doc . sessionId ) ;
const draft = docs . get ( draftId ) ;
if ( draft ) {
draft . _composeAtts = [ att ] ;
draft . _draftForMessageId = mid ;
if ( reply . account _id ) draft . _draftAccountId = reply . account _id ;
}
await loadDocument ( draftId ) ;
_renderComposeAttachments ( ) ;
if ( uiModule ) uiModule . showToast ( ` Reply draft ready — " ${ att . filename } " attached ` ) ;
}
/** Save manual edits */
export async function saveDocument ( { silent = false } = { } ) {
if ( ! activeDocId ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { content : textarea . value } ) ,
} ) ;
const doc = await res . json ( ) ;
const badge = document . getElementById ( 'doc-version-badge' ) ;
if ( badge ) { const _v = doc . version _count || 1 ; badge . textContent = ` v ${ _v } ` ; badge . style . display = _v > 1 ? '' : 'none' ; }
// Update map
if ( docs . has ( activeDocId ) ) {
docs . get ( activeDocId ) . version = doc . version _count || 1 ;
docs . get ( activeDocId ) . content = textarea . value ;
}
_syncDocIndicator ( ) ;
if ( ! silent && uiModule ) uiModule . showToast ( 'Document saved' ) ;
} catch ( e ) {
console . error ( 'Failed to save document:' , e ) ;
if ( ! silent && uiModule ) uiModule . showError ( 'Failed to save document' ) ;
}
}
/** Export/download the active document */
let _docxReady = null ;
function ensureDocx ( ) {
if ( _docxReady ) return _docxReady ;
if ( window . docx ) return ( _docxReady = Promise . resolve ( ) ) ;
_docxReady = new Promise ( ( resolve , reject ) => {
const s = document . createElement ( 'script' ) ;
s . src = '/static/lib/docx.umd.min.js' ;
s . onload = resolve ;
s . onerror = ( ) => reject ( new Error ( 'Failed to load DOCX library' ) ) ;
document . head . appendChild ( s ) ;
} ) ;
return _docxReady ;
}
let _html2pdfReady = null ;
function ensureHtml2Pdf ( ) {
if ( _html2pdfReady ) return _html2pdfReady ;
if ( window . html2pdf ) return ( _html2pdfReady = Promise . resolve ( ) ) ;
_html2pdfReady = new Promise ( ( resolve , reject ) => {
const s = document . createElement ( 'script' ) ;
s . src = '/static/lib/html2pdf.bundle.min.js' ;
s . onload = resolve ;
s . onerror = ( ) => reject ( new Error ( 'Failed to load PDF library' ) ) ;
document . head . appendChild ( s ) ;
} ) ;
return _html2pdfReady ;
}
function _getExportBaseName ( ) {
const doc = docs . get ( activeDocId ) ;
const title = ( doc && doc . title ) || 'document' ;
const safeName = title . replace ( /[^a-zA-Z0-9_\-. ]/g , '_' ) . trim ( ) || 'document' ;
const ver = doc && doc . version ? ` _v ${ doc . version } ` : '' ;
return safeName + ver ;
}
function exportDocument ( ) {
if ( ! activeDocId ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
const doc = docs . get ( activeDocId ) ;
const title = ( doc && doc . title ) || 'document' ;
const lang = document . getElementById ( 'doc-language-select' ) ? . value || '' ;
const extMap = {
javascript : '.js' , python : '.py' , html : '.html' , css : '.css' ,
markdown : '.md' , json : '.json' , yaml : '.yml' , bash : '.sh' ,
sql : '.sql' , rust : '.rs' , go : '.go' , java : '.java' , c : '.c' , cpp : '.cpp' , csharp : '.cs' ,
typescript : '.ts' , ruby : '.rb' , php : '.php' , text : '.txt' ,
xml : '.xml' , toml : '.toml' , ini : '.ini' , csv : '.csv' ,
} ;
const ext = extMap [ lang ] || '.txt' ;
const safeName = title . replace ( /[^a-zA-Z0-9_\-. ]/g , '_' ) . trim ( ) || 'document' ;
const ver = doc && doc . version ? ` _v ${ doc . version } ` : '' ;
const mime = lang === 'csv' ? 'text/csv' : lang === 'json' ? 'application/json' : 'text/plain' ;
const blob = new Blob ( [ textarea . value ] , { type : mime } ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = safeName + ver + ext ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
}
// "Import from device" — open a file picker, upload, and immediately open
// the resulting doc in THIS panel (vs. dumping it in the library and
// making the user click through). Mirrors the library's extension logic
// for text/code; routes PDFs through the dedicated import-pdf endpoint
// that handles AcroForm fields. Spreadsheets fall back to the library
// flow which already knows how to split sheets.
function _importFromDevice ( ) {
const EXT _TO _LANG = {
'.py' : 'python' , '.js' : 'javascript' , '.ts' : 'typescript' , '.html' : 'html' , '.htm' : 'html' ,
'.css' : 'css' , '.md' : 'markdown' , '.json' : 'json' , '.yml' : 'yaml' , '.yaml' : 'yaml' ,
'.sh' : 'bash' , '.bash' : 'bash' , '.sql' : 'sql' , '.rs' : 'rust' , '.go' : 'go' ,
'.java' : 'java' , '.c' : 'c' , '.cpp' : 'cpp' , '.h' : 'c' , '.hpp' : 'cpp' ,
'.rb' : 'ruby' , '.php' : 'php' , '.xml' : 'xml' , '.toml' : 'toml' , '.ini' : 'ini' ,
'.txt' : '' , '.log' : '' , '.csv' : 'csv' , '.tsv' : 'csv' , '.jsx' : 'javascript' , '.tsx' : 'typescript' ,
} ;
const fi = document . createElement ( 'input' ) ;
fi . type = 'file' ;
fi . style . display = 'none' ;
fi . addEventListener ( 'change' , async ( ) => {
const file = fi . files ? . [ 0 ] ;
if ( ! file ) return ;
const name = file . name ;
const dotIdx = name . lastIndexOf ( '.' ) ;
const ext = dotIdx >= 0 ? name . slice ( dotIdx ) . toLowerCase ( ) : '' ;
const baseTitle = dotIdx > 0 ? name . slice ( 0 , dotIdx ) : name ;
const isSpreadsheet = [ '.xlsx' , '.xls' , '.ods' ] . includes ( ext ) ;
const isPdf = ext === '.pdf' ;
// Spreadsheets need the library's per-sheet split — defer to it.
if ( isSpreadsheet ) {
openLibrary ( ) ;
requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => document . getElementById ( 'doclib-import-file-btn' ) ? . click ( ) ) ) ;
return ;
}
try {
let docId = null ;
if ( isPdf ) {
const fd = new FormData ( ) ;
fd . append ( 'file' , file ) ;
const sid = ( sessionModule && sessionModule . getCurrentSessionId && sessionModule . getCurrentSessionId ( ) ) || _lastSessionId || '' ;
if ( sid ) fd . append ( 'session_id' , sid ) ;
const r = await fetch ( ` ${ API _BASE } /api/documents/import-pdf ` , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
if ( ! r . ok ) throw new Error ( 'PDF import failed' ) ;
const j = await r . json ( ) ;
docId = j . doc _id || j . id ;
} else {
const content = await new Promise ( ( res , rej ) => {
const reader = new FileReader ( ) ;
reader . onload = ( ) => res ( reader . result || '' ) ;
reader . onerror = ( ) => rej ( reader . error ) ;
reader . readAsText ( file ) ;
} ) ;
const lang = EXT _TO _LANG [ ext ] !== undefined ? EXT _TO _LANG [ ext ] : null ;
const sid = ( sessionModule && sessionModule . getCurrentSessionId && sessionModule . getCurrentSessionId ( ) ) || _lastSessionId || '' ;
const body = { title : baseTitle , language : lang , content } ;
if ( sid ) body . session _id = sid ;
const r = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'same-origin' ,
body : JSON . stringify ( body ) ,
} ) ;
if ( ! r . ok ) throw new Error ( 'Import failed' ) ;
const j = await r . json ( ) ;
docId = j . id || j . doc _id ;
}
if ( docId ) {
// Fetch the full doc so addDocToTabs has the proper content +
// language fields (it's used downstream by switchToDoc).
try {
const dr = await fetch ( ` ${ API _BASE } /api/document/ ${ docId } ` , { credentials : 'same-origin' } ) ;
const full = dr . ok ? await dr . json ( ) : { id : docId , title : baseTitle } ;
const sid = ( sessionModule && sessionModule . getCurrentSessionId && sessionModule . getCurrentSessionId ( ) ) || _lastSessionId || '' ;
addDocToTabs ( full , full . session _id || sid ) ;
switchToDoc ( full . id || docId ) ;
} catch ( _ ) {
// Fallback — at least try to switch (may fail silently if not loaded).
addDocToTabs ( { id : docId , title : baseTitle } , _lastSessionId || '' ) ;
switchToDoc ( docId ) ;
}
}
} catch ( err ) {
if ( uiModule && uiModule . showError ) uiModule . showError ( 'Import failed: ' + ( err . message || err ) ) ;
} finally {
fi . value = '' ;
fi . remove ( ) ;
}
} ) ;
document . body . appendChild ( fi ) ;
fi . click ( ) ;
}
function showExportMenu ( e , anchorRect ) {
if ( e ) e . stopPropagation ( ) ;
// Remove existing menu if any
const existing = document . getElementById ( 'doc-export-menu' ) ;
if ( existing ) { existing . remove ( ) ; return ; }
// Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect
|| ( e && e . target && e . target . closest ( 'button' ) ? . getBoundingClientRect ( ) )
|| document . getElementById ( 'doc-language-select' ) ? . getBoundingClientRect ( ) ;
if ( ! rect ) return ;
const lang = document . getElementById ( 'doc-language-select' ) ? . value || '' ;
const extMap = {
javascript : '.js' , python : '.py' , html : '.html' , css : '.css' ,
markdown : '.md' , json : '.json' , yaml : '.yml' , bash : '.sh' ,
sql : '.sql' , rust : '.rs' , go : '.go' , java : '.java' , c : '.c' , cpp : '.cpp' , csharp : '.cs' ,
typescript : '.ts' , ruby : '.rb' , php : '.php' , text : '.txt' ,
xml : '.xml' , toml : '.toml' , ini : '.ini' , csv : '.csv' ,
} ;
const ext = extMap [ lang ] || '.txt' ;
const menu = document . createElement ( 'div' ) ;
menu . id = 'doc-export-menu' ;
menu . className = 'doc-overflow-menu open' ;
menu . style . position = 'fixed' ;
menu . style . top = ( rect . bottom + 2 ) + 'px' ;
menu . style . right = ( window . innerWidth - rect . right ) + 'px' ;
menu . style . left = 'auto' ;
menu . style . zIndex = '9999' ;
const langLabel = lang ? lang . toUpperCase ( ) : 'TXT' ;
// Form-backed markdown doc → primary export is the filled PDF, not the
// markdown source. Promote it to the top of the menu.
const liveContent = document . getElementById ( 'doc-editor-textarea' ) ? . value
|| docs . get ( activeDocId ) ? . content || '' ;
const isForm = _isFormBackedDoc ( liveContent ) ;
const options = [ ] ;
// Import lives at the top of the same dropdown — it's a sibling action
// ("bring something IN" vs "send something OUT"), and the footer was
// getting too cramped for dedicated icons.
options . push ( { label : 'Import from library' , fn : ( ) => openLibrary ( ) } ) ;
options . push ( { label : 'Import from device' , fn : ( ) => _importFromDevice ( ) , _divider : true } ) ;
if ( isForm ) options . push ( { label : 'Filled PDF (.pdf)' , fn : _downloadFilledPdf } ) ;
options . push (
{ label : 'Export Markdown' , fn : exportDocument } ,
{ label : 'Print as PDF' , fn : exportAsPdf } ,
{ label : 'Export as Word' , fn : exportAsDocx } ,
) ;
options . forEach ( opt => {
const item = document . createElement ( 'button' ) ;
item . className = 'doc-overflow-item' ;
item . textContent = opt . label ;
item . addEventListener ( 'click' , ( ev ) => { ev . stopPropagation ( ) ; menu . remove ( ) ; opt . fn ( ) ; } ) ;
menu . appendChild ( item ) ;
if ( opt . _divider ) {
const sep = document . createElement ( 'div' ) ;
sep . className = 'doc-overflow-divider' ;
sep . style . cssText = 'height:1px;margin:3px 6px;background:color-mix(in srgb,var(--border) 60%,transparent);' ;
menu . appendChild ( sep ) ;
}
} ) ;
document . body . appendChild ( menu ) ;
// Flip above the anchor when there's no room below — the Export button now
// lives in the bottom footer, so the menu would otherwise drop off-screen.
const mh = menu . offsetHeight ;
if ( rect . bottom + mh > window . innerHeight - 8 ) {
menu . style . top = 'auto' ;
menu . style . bottom = ( window . innerHeight - rect . top + 2 ) + 'px' ;
}
const close = ( ev ) => {
if ( ev && ev . type === 'keydown' ) {
if ( ev . key !== 'Escape' ) return ;
ev . preventDefault ( ) ;
ev . stopPropagation ( ) ;
ev . stopImmediatePropagation ? . ( ) ;
} else if ( ev && menu . contains ( ev . target ) ) {
return ;
}
menu . remove ( ) ;
document . removeEventListener ( 'click' , close ) ;
document . removeEventListener ( 'keydown' , close , true ) ;
} ;
setTimeout ( ( ) => document . addEventListener ( 'click' , close ) , 100 ) ;
document . addEventListener ( 'keydown' , close , true ) ;
}
function exportAsHtml ( ) {
if ( ! activeDocId ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
const lang = document . getElementById ( 'doc-language-select' ) ? . value || '' ;
const text = textarea . value || '' ;
let body ;
if ( lang === 'markdown' && markdownModule ? . mdToHtml ) {
body = markdownModule . mdToHtml ( text ) ;
} else {
body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' +
text . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) + '</pre>' ;
}
const title = docs . get ( activeDocId ) ? . title || 'document' ;
const html = ` <!DOCTYPE html> \n <html><head><meta charset="utf-8"><title> ${ title . replace ( /</g , '<' ) } </title></head><body style="max-width:800px;margin:40px auto;font-family:sans-serif;line-height:1.6;padding:0 20px;"> \n ${ body } \n </body></html> ` ;
const blob = new Blob ( [ html ] , { type : 'text/html' } ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = _getExportBaseName ( ) + '.html' ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
if ( uiModule ) uiModule . showToast ( 'Exported as HTML' ) ;
}
async function exportAsPdf ( ) {
if ( ! activeDocId ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
try {
await ensureHtml2Pdf ( ) ;
} catch ( e ) {
if ( uiModule ) uiModule . showError ( 'Failed to load PDF library' ) ;
return ;
}
const lang = document . getElementById ( 'doc-language-select' ) ? . value || '' ;
const text = textarea . value || '' ;
// Render content as HTML for PDF
let html ;
if ( lang === 'markdown' && markdownModule ? . mdToHtml ) {
html = markdownModule . mdToHtml ( text ) ;
} else {
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
text . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) + '</pre>' ;
}
const container = document . createElement ( 'div' ) ;
container . style . cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;' ;
container . innerHTML = html ;
const baseName = _getExportBaseName ( ) ;
window . html2pdf ( ) . set ( {
margin : 10 ,
filename : baseName + '.pdf' ,
image : { type : 'jpeg' , quality : 0.95 } ,
html2canvas : { scale : 2 } ,
jsPDF : { unit : 'mm' , format : 'a4' , orientation : 'portrait' } ,
} ) . from ( container ) . save ( ) ;
if ( uiModule ) uiModule . showToast ( 'Exporting PDF...' ) ;
}
async function exportAsDocx ( ) {
if ( ! activeDocId ) return ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! textarea ) return ;
try {
await ensureDocx ( ) ;
} catch ( e ) {
if ( uiModule ) uiModule . showError ( 'Failed to load DOCX library' ) ;
return ;
}
const text = textarea . value || '' ;
const { Document , Packer , Paragraph , TextRun , HeadingLevel } = window . docx ;
// Parse text into paragraphs, handle markdown headings
const paragraphs = text . split ( '\n' ) . map ( line => {
const h1 = line . match ( /^# (.+)/ ) ;
const h2 = line . match ( /^## (.+)/ ) ;
const h3 = line . match ( /^### (.+)/ ) ;
if ( h1 ) return new Paragraph ( { text : h1 [ 1 ] , heading : HeadingLevel . HEADING _1 } ) ;
if ( h2 ) return new Paragraph ( { text : h2 [ 1 ] , heading : HeadingLevel . HEADING _2 } ) ;
if ( h3 ) return new Paragraph ( { text : h3 [ 1 ] , heading : HeadingLevel . HEADING _3 } ) ;
// Handle bold/italic
const runs = [ ] ;
const parts = line . split ( /(\*\*[^*]+\*\*|\*[^*]+\*)/ ) ;
for ( const part of parts ) {
if ( part . startsWith ( '**' ) && part . endsWith ( '**' ) ) {
runs . push ( new TextRun ( { text : part . slice ( 2 , - 2 ) , bold : true } ) ) ;
} else if ( part . startsWith ( '*' ) && part . endsWith ( '*' ) ) {
runs . push ( new TextRun ( { text : part . slice ( 1 , - 1 ) , italics : true } ) ) ;
} else {
runs . push ( new TextRun ( part ) ) ;
}
}
return new Paragraph ( { children : runs } ) ;
} ) ;
const doc = new Document ( {
sections : [ { children : paragraphs } ] ,
} ) ;
const blob = await Packer . toBlob ( doc ) ;
const baseName = _getExportBaseName ( ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = baseName + '.docx' ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
if ( uiModule ) uiModule . showToast ( 'Exported as DOCX' ) ;
}
/** Delete the active document */
async function deleteActiveDocument ( ) {
if ( ! activeDocId ) return ;
const doc = docs . get ( activeDocId ) ;
const name = doc ? doc . title : 'this document' ;
const ok = uiModule && uiModule . styledConfirm
? await uiModule . styledConfirm ( ` Delete " ${ name } "? ` , { confirmText : 'Delete' , danger : true } )
: confirm ( ` Delete " ${ name } "? ` ) ;
if ( ! ok ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } ` , { method : 'DELETE' } ) ;
if ( ! res . ok ) throw new Error ( 'Delete failed' ) ;
// Remove tab
const tab = document . querySelector ( ` .doc-tab[data-doc-id=" ${ activeDocId } "] ` ) ;
if ( tab ) tab . remove ( ) ;
docs . delete ( activeDocId ) ;
// Switch to another doc or close panel
const remaining = Array . from ( docs . keys ( ) ) ;
if ( remaining . length > 0 ) {
switchToDoc ( remaining [ 0 ] ) ;
} else {
activeDocId = null ;
closePanel ( ) ;
}
if ( uiModule ) uiModule . showToast ( 'Document deleted' ) ;
} catch ( e ) {
console . error ( 'Failed to delete document:' , e ) ;
if ( uiModule ) uiModule . showError ( 'Failed to delete document' ) ;
}
}
/** Toggle fullscreen on doc editor pane */
function toggleFullscreen ( ) {
const pane = document . getElementById ( 'doc-editor-pane' ) ;
const container = document . getElementById ( 'chat-container' ) ;
if ( ! pane ) return ;
// Note: the divider stays in the DOM during fullscreen so its chevron can
// act as the exit-fullscreen affordance (the CSS rule
// `body:has(.doc-editor-pane.doc-fullscreen) .doc-divider-collapse` slides
// it into a forced-inside position). Hiding the divider here would hide
// the chevron with it.
if ( pane . classList . contains ( 'doc-fullscreen' ) ) {
pane . classList . remove ( 'doc-fullscreen' ) ;
if ( container ) container . style . display = '' ;
} else {
pane . classList . add ( 'doc-fullscreen' ) ;
if ( container ) container . style . display = 'none' ;
}
// Re-check md toolbar overflow after layout change
const mdToolbar = document . getElementById ( 'doc-md-toolbar' ) ;
if ( mdToolbar ? . _syncOverflow ) requestAnimationFrame ( mdToolbar . _syncOverflow ) ;
}
/** Toggle markdown preview */
function _setMarkdownPreviewActive ( active , { remember = true } = { } ) {
const preview = document . getElementById ( 'doc-md-preview' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! preview || ! wrap || ! textarea ) return ;
if ( active ) {
const md = textarea . value || '' ;
if ( markdownModule && markdownModule . mdToHtml ) {
preview . innerHTML = markdownModule . mdToHtml ( md ) ;
} else {
preview . innerHTML = md . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /\n/g , '<br>' ) ;
}
if ( window . hljs ) {
preview . querySelectorAll ( 'pre code' ) . forEach ( b => window . hljs . highlightElement ( b ) ) ;
}
preview . style . display = '' ;
wrap . style . display = 'none' ;
} else {
preview . style . display = 'none' ;
preview . innerHTML = '' ;
const isEmailDoc = docs . get ( activeDocId ) ? . language === 'email' ;
const richEmailBody = document . getElementById ( 'doc-email-richbody' ) ;
if ( ! ( isEmailDoc && richEmailBody && richEmailBody . style . display !== 'none' ) ) {
wrap . style . display = '' ;
}
}
if ( remember && activeDocId && docs . has ( activeDocId ) ) {
docs . get ( activeDocId ) . _markdownPreviewActive = ! ! active ;
}
_syncHeaderActions ( ) ;
}
function toggleMarkdownPreview ( ) {
const preview = document . getElementById ( 'doc-md-preview' ) ;
_setMarkdownPreviewActive ( ! ( preview && preview . style . display !== 'none' ) ) ;
}
/** Parse CSV text into a 2D array (handles quoted fields) */
function parseCSV ( text ) {
const rows = [ ] ;
let row = [ ] ;
let field = '' ;
let inQuotes = false ;
for ( let i = 0 ; i < text . length ; i ++ ) {
const ch = text [ i ] ;
if ( inQuotes ) {
if ( ch === '"' && text [ i + 1 ] === '"' ) { field += '"' ; i ++ ; }
else if ( ch === '"' ) { inQuotes = false ; }
else { field += ch ; }
} else {
if ( ch === '"' ) { inQuotes = true ; }
else if ( ch === ',' ) { row . push ( field ) ; field = '' ; }
else if ( ch === '\n' || ( ch === '\r' && text [ i + 1 ] === '\n' ) ) {
if ( ch === '\r' ) i ++ ;
row . push ( field ) ; field = '' ;
if ( row . some ( c => c . trim ( ) ) ) rows . push ( row ) ;
row = [ ] ;
} else { field += ch ; }
}
}
row . push ( field ) ;
if ( row . some ( c => c . trim ( ) ) ) rows . push ( row ) ;
return rows ;
}
/** Escape a CSV field (quote if it contains comma, quote, or newline) */
function csvEscapeField ( val ) {
if ( val . includes ( ',' ) || val . includes ( '"' ) || val . includes ( '\n' ) ) {
return '"' + val . replace ( /"/g , '""' ) + '"' ;
}
return val ;
}
/** Rebuild CSV text from the live table DOM */
function syncTableToTextarea ( preview , textarea ) {
const table = preview . querySelector ( '.csv-table' ) ;
if ( ! table ) return ;
const lines = [ ] ;
// Header
const ths = table . querySelectorAll ( 'thead th' ) ;
if ( ths . length ) lines . push ( [ ... ths ] . map ( th => csvEscapeField ( th . textContent ) ) . join ( ',' ) ) ;
// Body
table . querySelectorAll ( 'tbody tr' ) . forEach ( tr => {
const cells = [ ... tr . querySelectorAll ( 'td' ) ] . map ( td => csvEscapeField ( td . textContent ) ) ;
lines . push ( cells . join ( ',' ) ) ;
} ) ;
textarea . value = lines . join ( '\n' ) + '\n' ;
textarea . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
}
/** Toggle CSV table preview */
function toggleCsvPreview ( ) {
const preview = document . getElementById ( 'doc-csv-preview' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! preview || ! wrap || ! textarea ) return ;
if ( preview . style . display === 'none' ) {
const rows = parseCSV ( textarea . value || '' ) ;
if ( rows . length === 0 ) {
// Re-route the "no data" message to the shared run-output block so
// every doc type surfaces errors/empty-state in the same place
// (instead of stamping it inside the table view itself).
let outputPanel = document . getElementById ( 'doc-run-output' ) ;
if ( ! outputPanel ) {
outputPanel = document . createElement ( 'div' ) ;
outputPanel . id = 'doc-run-output' ;
outputPanel . className = 'doc-run-output' ;
const editorWrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( editorWrap ) editorWrap . after ( outputPanel ) ;
}
outputPanel . style . display = 'block' ;
outputPanel . innerHTML = '<pre class="doc-run-error">No data — CSV is empty or unparseable.</pre>' ;
return ;
} else {
const esc = s => s . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
const colCount = Math . max ( ... rows . map ( r => r . length ) ) ;
let html = '<div class="csv-table-wrap"><table class="csv-table"><thead><tr>' ;
for ( let j = 0 ; j < colCount ; j ++ ) {
html += ` <th contenteditable="true"> ${ esc ( rows [ 0 ] [ j ] || '' ) } </th> ` ;
}
html += '</tr></thead><tbody>' ;
for ( let i = 1 ; i < rows . length ; i ++ ) {
html += '<tr>' ;
for ( let j = 0 ; j < colCount ; j ++ ) {
html += ` <td contenteditable="true"> ${ esc ( rows [ i ] [ j ] || '' ) } </td> ` ;
}
html += '</tr>' ;
}
html += '</tbody></table>' ;
html += '</div>' ;
preview . innerHTML = html ;
// Sync edits back to textarea
const table = preview . querySelector ( '.csv-table' ) ;
if ( table ) {
table . addEventListener ( 'input' , ( ) => syncTableToTextarea ( preview , textarea ) ) ;
// Prevent Enter from creating <br> inside cells
table . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' && ! e . shiftKey ) {
e . preventDefault ( ) ;
// Move to next row, same column
const cell = e . target . closest ( 'td,th' ) ;
if ( ! cell ) return ;
const colIdx = [ ... cell . parentElement . children ] . indexOf ( cell ) ;
const nextRow = cell . parentElement . nextElementSibling ;
if ( nextRow && nextRow . children [ colIdx ] ) {
nextRow . children [ colIdx ] . focus ( ) ;
}
} else if ( e . key === 'Tab' ) {
e . preventDefault ( ) ;
const cell = e . target . closest ( 'td,th' ) ;
if ( ! cell ) return ;
const next = e . shiftKey ? cell . previousElementSibling : cell . nextElementSibling ;
if ( next ) next . focus ( ) ;
}
} ) ;
}
// Add row button
const addBtn = preview . querySelector ( '.csv-add-row-btn' ) ;
if ( addBtn && table ) {
addBtn . addEventListener ( 'click' , ( ) => {
const tbody = table . querySelector ( 'tbody' ) ;
const tr = document . createElement ( 'tr' ) ;
for ( let j = 0 ; j < colCount ; j ++ ) {
const td = document . createElement ( 'td' ) ;
td . contentEditable = 'true' ;
tr . appendChild ( td ) ;
}
tbody . appendChild ( tr ) ;
tr . children [ 0 ] . focus ( ) ;
syncTableToTextarea ( preview , textarea ) ;
} ) ;
}
}
preview . style . display = '' ;
wrap . style . display = 'none' ;
} else {
preview . style . display = 'none' ;
wrap . style . display = '' ;
}
// Update the segmented Code/Run toggle's active class so the icon
// highlights match the new state — without this, opening a CSV that
// auto-shows the table view left the Edit (code) side wrongly marked
// active and the user had to flip the toggle to resync.
_syncHeaderActions ( ) ;
}
/** Toggle inline HTML preview (iframe) */
function toggleHtmlPreview ( ) {
const iframe = document . getElementById ( 'doc-html-preview' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ! iframe || ! wrap || ! textarea ) return ;
if ( ! _htmlPreviewActive ) {
// Show preview — hide markdown preview if active
const mdPreview = document . getElementById ( 'doc-md-preview' ) ;
if ( mdPreview ) mdPreview . style . display = 'none' ;
const code = textarea . value || '' ;
iframe . srcdoc = code ;
iframe . style . display = '' ;
wrap . style . display = 'none' ;
_htmlPreviewActive = true ;
renderTabs ( ) ;
} else {
exitHtmlPreview ( ) ;
}
}
/** Exit HTML preview back to code view */
function exitHtmlPreview ( ) {
const iframe = document . getElementById ( 'doc-html-preview' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( ! _htmlPreviewActive ) return ;
_htmlPreviewActive = false ;
if ( iframe ) { iframe . style . display = 'none' ; iframe . srcdoc = '' ; }
if ( wrap ) wrap . style . display = '' ;
renderTabs ( ) ;
}
// ---- Streaming animation engine ----
/ * *
* Simple diff : find the first and last differing positions between two strings .
* Returns { prefixLen , oldMid , newMid } where :
* oldText = prefix + oldMid + suffix
* newText = prefix + newMid + suffix
* /
function simpleDiff ( oldText , newText ) {
let i = 0 ;
const minLen = Math . min ( oldText . length , newText . length ) ;
while ( i < minLen && oldText [ i ] === newText [ i ] ) i ++ ;
const prefixLen = i ;
let oj = oldText . length ;
let nj = newText . length ;
while ( oj > prefixLen && nj > prefixLen && oldText [ oj - 1 ] === newText [ nj - 1 ] ) {
oj -- ; nj -- ;
}
return {
prefixLen ,
oldMid : oldText . slice ( prefixLen , oj ) ,
newMid : newText . slice ( prefixLen , nj ) ,
} ;
}
/ * *
* Animate the transition from oldText to newText in the editor textarea .
* First deletes the old differing section char - by - char , then types the new one .
* /
/ * *
* Compute a line - level diff between two texts .
* Returns array of { type : 'same' | 'del' | 'add' , text : string }
* /
function lineDiff ( oldText , newText ) {
const oldLines = oldText . split ( '\n' ) ;
const newLines = newText . split ( '\n' ) ;
// Simple LCS-based diff (Myers-like, but O(n*m) for clarity)
const m = oldLines . length , n = newLines . length ;
// For very large diffs, skip detailed diff
if ( m * n > 500000 ) return null ;
const dp = Array . from ( { length : m + 1 } , ( ) => new Uint16Array ( n + 1 ) ) ;
for ( let i = m - 1 ; i >= 0 ; i -- ) {
for ( let j = n - 1 ; j >= 0 ; j -- ) {
if ( oldLines [ i ] === newLines [ j ] ) {
dp [ i ] [ j ] = dp [ i + 1 ] [ j + 1 ] + 1 ;
} else {
dp [ i ] [ j ] = Math . max ( dp [ i + 1 ] [ j ] , dp [ i ] [ j + 1 ] ) ;
}
}
}
const result = [ ] ;
let i = 0 , j = 0 ;
while ( i < m || j < n ) {
if ( i < m && j < n && oldLines [ i ] === newLines [ j ] ) {
result . push ( { type : 'same' , text : oldLines [ i ] } ) ;
i ++ ; j ++ ;
} else if ( j < n && ( i >= m || dp [ i ] [ j + 1 ] >= dp [ i + 1 ] [ j ] ) ) {
result . push ( { type : 'add' , text : newLines [ j ] } ) ;
j ++ ;
} else {
result . push ( { type : 'del' , text : oldLines [ i ] } ) ;
i ++ ;
}
}
return result ;
}
async function animateDocChange ( oldText , newText ) {
if ( _animationCancel ) _animationCancel ( ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( ! textarea ) return false ;
if ( oldText === newText ) return true ;
const diff = lineDiff ( oldText , newText ) ;
if ( ! diff ) return false ; // too large for diff
// Count changes
const delCount = diff . filter ( d => d . type === 'del' ) . length ;
const addCount = diff . filter ( d => d . type === 'add' ) . length ;
if ( delCount + addCount === 0 ) return true ;
_animationInProgress = true ;
let cancelled = false ;
_animationCancel = ( ) => { cancelled = true ; } ;
textarea . readOnly = true ;
if ( wrap ) wrap . classList . add ( 'animating' ) ;
try {
// Build diff overlay HTML
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'doc-diff-overlay' ;
// Stats bar
const stats = document . createElement ( 'div' ) ;
stats . className = 'doc-diff-stats' ;
stats . innerHTML = ` <span class="diff-stat-del"> \u 2212 ${ delCount } </span> <span class="diff-stat-add">+ ${ addCount } </span> ` ;
overlay . appendChild ( stats ) ;
const content = document . createElement ( 'div' ) ;
content . className = 'doc-diff-content' ;
// Render diff lines — show context around changes
let inContext = false ;
let skipped = 0 ;
diff . forEach ( ( line , idx ) => {
if ( line . type === 'same' ) {
// Show 2 lines of context around changes
const nearChange = diff . slice ( Math . max ( 0 , idx - 2 ) , idx + 3 ) . some ( d => d . type !== 'same' ) ;
if ( nearChange ) {
if ( skipped > 0 ) {
const sep = document . createElement ( 'div' ) ;
sep . className = 'doc-diff-sep' ;
sep . textContent = ` \u 22EF ${ skipped } unchanged ` ;
content . appendChild ( sep ) ;
skipped = 0 ;
}
const row = document . createElement ( 'div' ) ;
row . className = 'doc-diff-line same' ;
row . textContent = line . text || '\u00A0' ;
content . appendChild ( row ) ;
} else {
skipped ++ ;
}
} else {
if ( skipped > 0 ) {
const sep = document . createElement ( 'div' ) ;
sep . className = 'doc-diff-sep' ;
sep . textContent = ` \u 22EF ${ skipped } unchanged ` ;
content . appendChild ( sep ) ;
skipped = 0 ;
}
const row = document . createElement ( 'div' ) ;
row . className = 'doc-diff-line ' + line . type ;
row . textContent = ( line . type === 'del' ? '\u2212 ' : '+ ' ) + ( line . text || '\u00A0' ) ;
content . appendChild ( row ) ;
}
} ) ;
overlay . appendChild ( content ) ;
// Insert overlay over the textarea
const editorArea = textarea . parentElement ;
if ( editorArea ) editorArea . appendChild ( overlay ) ;
// Show diff for a moment, then fade to final content
overlay . offsetHeight ; // force reflow
overlay . classList . add ( 'visible' ) ;
const DIFF _DISPLAY _MS = 2500 ;
await new Promise ( r => setTimeout ( r , cancelled ? 0 : DIFF _DISPLAY _MS ) ) ;
if ( ! cancelled ) {
overlay . classList . remove ( 'visible' ) ;
overlay . classList . add ( 'fading' ) ;
textarea . value = newText ;
syncHighlighting ( ) ;
await new Promise ( r => setTimeout ( r , 400 ) ) ;
}
overlay . remove ( ) ;
if ( ! cancelled ) {
textarea . value = newText ;
syncHighlighting ( ) ;
}
return ! cancelled ;
} finally {
textarea . readOnly = false ;
_animationInProgress = false ;
_animationCancel = null ;
if ( wrap ) wrap . classList . remove ( 'animating' ) ;
}
}
// --- Streaming helpers: open panel & feed content as AI generates ---
let _streamDocId = null ;
/ * * S y n c t h e m a r k d o w n t o o l b a r + h e a d e r a c t i o n s f o r a s t r e a m i n g d o c , s o t h e
* Edit / Preview toggle and formatting tools appear without a manual refresh . * /
function _syncStreamDocChrome ( doc ) {
if ( ! doc ) return ;
const lang = ( doc . language || 'markdown' ) . toLowerCase ( ) ;
const isMd = lang === 'markdown' ;
const isPdf = _isFormBackedDoc ( doc . content || '' ) ;
// Show the toolbar for any doc type that has a view toggle of its own
// (markdown edit↔preview, or code↔run for renderable code types). The
// `data-mode` attribute lets CSS hide markdown-only buttons (bold,
// italic, headings, etc.) when we're in a code-mode doc.
const renderable = [ 'svg' , 'html' , 'css' , 'csv' , 'python' , 'javascript' , 'typescript' ,
'json' , 'xml' , 'bash' , 'sh' , 'yaml' , 'toml' , 'sql' ] ;
const isCodeRenderable = renderable . includes ( lang ) ;
const mt = document . getElementById ( 'doc-md-toolbar' ) ;
if ( mt ) {
const showToolbar = isMd || isPdf || isCodeRenderable ;
mt . style . display = showToolbar ? '' : 'none' ;
mt . dataset . mode = isMd ? 'md' : ( isPdf ? 'pdf' : ( isCodeRenderable ? 'code' : '' ) ) ;
if ( showToolbar && mt . _syncOverflow ) requestAnimationFrame ( mt . _syncOverflow ) ;
}
_syncHeaderActions ( ) ;
}
/** Open the document panel immediately for a doc being streamed in */
export function streamDocOpen ( title , language ) {
// If already streaming a doc, reuse it (don't create a second temp doc)
if ( _streamDocId && docs . has ( _streamDocId ) ) {
const existing = docs . get ( _streamDocId ) ;
if ( title ) existing . title = title ;
if ( language ) existing . language = language ;
// Update UI fields
const titleInput = document . getElementById ( 'doc-title-input' ) ;
const langSelect = document . getElementById ( 'doc-language-select' ) ;
if ( title && titleInput ) titleInput . value = title ;
if ( langSelect ) langSelect . value = existing . language || 'markdown' ;
if ( language === 'email' ) {
_showEmailFields ( existing ) ;
}
_syncStreamDocChrome ( existing ) ;
renderTabs ( ) ;
return ;
}
const sessionId = sessionModule ? . getCurrentSessionId ( ) || '' ;
// Reuse existing doc with same title in this session, or create a temp one
let docId = null ;
if ( title ) {
for ( const [ existingId , existingDoc ] of docs ) {
if ( existingDoc . title === title && existingDoc . sessionId === sessionId ) {
docId = existingId ;
break ;
}
}
}
if ( ! docId ) {
docId = '_streaming_' + Date . now ( ) ;
docs . set ( docId , {
id : docId ,
title : title || '' ,
language : language || '' ,
content : '' ,
version : 1 ,
sessionId ,
} ) ;
}
_streamDocId = docId ;
activeDocId = docId ;
_syncDocIndicator ( ) ;
if ( ! isOpen ) openPanel ( ) ;
// Force doc button visible
const toggleBtn = document . getElementById ( 'overflow-doc-btn' ) ;
if ( toggleBtn ) {
toggleBtn . style . display = '' ;
toggleBtn . classList . remove ( 'toolbar-collapsed' ) ;
toggleBtn . classList . add ( 'has-docs' ) ;
}
const docInd2 = document . getElementById ( 'doc-indicator-btn' ) ;
if ( docInd2 ) docInd2 . classList . add ( 'visible' ) ;
const titleInput = document . getElementById ( 'doc-title-input' ) ;
const langSelect = document . getElementById ( 'doc-language-select' ) ;
const badge = document . getElementById ( 'doc-version-badge' ) ;
if ( titleInput ) titleInput . value = title || '' ;
if ( langSelect ) langSelect . value = language || 'markdown' ;
if ( badge ) badge . textContent = 'v1' ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) {
textarea . disabled = false ;
textarea . placeholder = 'Document content...' ;
textarea . value = '' ;
}
// Show streaming indicator
const indicator = document . getElementById ( 'doc-stream-indicator' ) ;
if ( indicator ) indicator . style . display = '' ;
// Show email fields immediately when streaming an email doc so the user
// doesn't have to refresh for the editor to flip into email mode.
if ( language === 'email' ) {
const streamDoc = docs . get ( _streamDocId ) ;
if ( streamDoc ) _showEmailFields ( streamDoc ) ;
} else {
_hideEmailFields ( ) ;
}
syncHighlighting ( ) ;
_syncStreamDocChrome ( docs . get ( _streamDocId ) ) ;
renderTabs ( ) ;
}
/** Simulate streaming effect for doc edits */
let _editAnimFrame = null ;
function _animateDocEdit ( textarea , newContent ) {
if ( _editAnimFrame ) cancelAnimationFrame ( _editAnimFrame ) ;
const indicator = document . getElementById ( 'doc-stream-indicator' ) ;
if ( indicator ) indicator . style . display = '' ;
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
let cursor = document . getElementById ( 'doc-stream-cursor' ) ;
if ( ! cursor ) {
cursor = document . createElement ( 'span' ) ;
cursor . id = 'doc-stream-cursor' ;
cursor . className = 'doc-stream-cursor' ;
cursor . textContent = '\u258F' ;
}
const oldContent = textarea . value ;
// Find common prefix and suffix to isolate the changed region
let prefixLen = 0 ;
while ( prefixLen < oldContent . length && prefixLen < newContent . length &&
oldContent [ prefixLen ] === newContent [ prefixLen ] ) prefixLen ++ ;
let suffixLen = 0 ;
while ( suffixLen < ( oldContent . length - prefixLen ) &&
suffixLen < ( newContent . length - prefixLen ) &&
oldContent [ oldContent . length - 1 - suffixLen ] === newContent [ newContent . length - 1 - suffixLen ] ) suffixLen ++ ;
const deletedText = oldContent . slice ( prefixLen , oldContent . length - suffixLen ) ;
const insertedText = newContent . slice ( prefixLen , newContent . length - suffixLen ) ;
const suffix = oldContent . slice ( oldContent . length - suffixLen ) ;
// Phase 1: delete characters one by one, then Phase 2: insert
const deleteChunk = Math . max ( 2 , Math . ceil ( deletedText . length / 30 ) ) ;
const insertChunk = Math . max ( 2 , Math . ceil ( insertedText . length / 30 ) ) ;
let deletePos = deletedText . length ;
let insertPos = 0 ;
let phase = deletedText . length > 0 ? 'delete' : 'insert' ;
// Scroll to the edit region
const linesBefore = oldContent . slice ( 0 , prefixLen ) . split ( '\n' ) . length ;
const lineH = parseFloat ( getComputedStyle ( textarea ) . lineHeight ) || 20 ;
textarea . scrollTop = Math . max ( 0 , ( linesBefore - 3 ) * lineH ) ;
function tick ( ) {
if ( phase === 'delete' ) {
deletePos = Math . max ( 0 , deletePos - deleteChunk ) ;
const current = oldContent . slice ( 0 , prefixLen ) + deletedText . slice ( 0 , deletePos ) + suffix ;
textarea . value = current ;
if ( codeEl ) codeEl . textContent = current + '\n' ;
if ( codeEl && codeEl . parentElement ) codeEl . parentElement . appendChild ( cursor ) ;
updateLineNumbers ( current ) ;
if ( deletePos > 0 ) {
_editAnimFrame = requestAnimationFrame ( tick ) ;
} else {
phase = 'insert' ;
_editAnimFrame = requestAnimationFrame ( tick ) ;
}
} else {
insertPos = Math . min ( insertPos + insertChunk , insertedText . length ) ;
const current = newContent . slice ( 0 , prefixLen + insertPos ) + suffix ;
textarea . value = current ;
if ( codeEl ) codeEl . textContent = current + '\n' ;
if ( codeEl && codeEl . parentElement ) codeEl . parentElement . appendChild ( cursor ) ;
updateLineNumbers ( current ) ;
if ( insertPos < insertedText . length ) {
_editAnimFrame = requestAnimationFrame ( tick ) ;
} else {
// Done — set final content
textarea . value = newContent ;
_editAnimFrame = null ;
if ( indicator ) indicator . style . display = 'none' ;
if ( cursor ) cursor . remove ( ) ;
syncHighlighting ( ) ;
}
}
}
_editAnimFrame = requestAnimationFrame ( tick ) ;
}
/** Append streaming content to the currently-streaming doc */
let _streamHlDebounce = null ;
export function streamDocDelta ( content ) {
if ( ! _streamDocId ) return ;
const doc = docs . get ( _streamDocId ) ;
if ( doc ) doc . content = content ;
if ( _streamDocId === activeDocId ) {
if ( ( doc ? . language || '' ) . toLowerCase ( ) === 'email' ) {
_showEmailFields ( doc ) ;
return ;
}
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) {
textarea . value = content ;
// Auto-scroll to bottom as content streams in
textarea . scrollTop = textarea . scrollHeight ;
}
// Update text and line numbers immediately, debounce expensive highlighting
const codeEl = document . getElementById ( 'doc-editor-code' ) ;
if ( codeEl ) codeEl . textContent = content + '\n' ;
updateLineNumbers ( content ) ;
// Show blinking cursor at end of content
let cursor = document . getElementById ( 'doc-stream-cursor' ) ;
if ( ! cursor ) {
cursor = document . createElement ( 'span' ) ;
cursor . id = 'doc-stream-cursor' ;
cursor . className = 'doc-stream-cursor' ;
cursor . textContent = '\u258F' ;
}
if ( codeEl && codeEl . parentElement ) codeEl . parentElement . appendChild ( cursor ) ;
clearTimeout ( _streamHlDebounce ) ;
_streamHlDebounce = setTimeout ( syncHighlighting , 150 ) ;
}
}
/ * * F i n a l i z e s t r e a m i n g — c a l l e d w h e n d o c _ u p d a t e a r r i v e s w i t h t h e r e a l I D .
* Returns the old _streamDocId so handleDocUpdate can migrate temp → real . * /
export function streamDocFinalize ( ) {
const oldId = _streamDocId ;
_streamDocId = null ;
// Hide streaming indicator + cursor
const indicator = document . getElementById ( 'doc-stream-indicator' ) ;
if ( indicator ) indicator . style . display = 'none' ;
const cursor = document . getElementById ( 'doc-stream-cursor' ) ;
if ( cursor ) cursor . remove ( ) ;
// Final highlighting pass + auto-detect language
clearTimeout ( _streamHlDebounce ) ;
syncHighlighting ( ) ;
attemptAutoDetect ( ) ;
return oldId ;
}
/** Handle SSE doc_update event from AI */
export function handleDocUpdate ( data ) {
const streamingId = streamDocFinalize ( ) ;
let docId = data . doc _id ;
const newContent = data . content || '' ;
// Migrate streaming temp doc to real ID
if ( streamingId && streamingId . startsWith ( '_streaming_' ) && docs . has ( streamingId ) ) {
const tempDoc = docs . get ( streamingId ) ;
docs . delete ( streamingId ) ;
tempDoc . id = docId ;
tempDoc . version = data . version || 1 ;
if ( data . title ) tempDoc . title = data . title ;
if ( data . language ) tempDoc . language = data . language ;
tempDoc . content = newContent ;
docs . set ( docId , tempDoc ) ;
// Fix activeDocId reference
if ( activeDocId === streamingId ) activeDocId = docId ;
}
// Deduplicate: if a new doc has same title as existing doc in this session, update it instead
if ( ! docs . has ( docId ) ) {
const curSession = sessionModule ? . getCurrentSessionId ( ) || '' ;
let reuseId = null ;
// First: match by title
if ( data . title ) {
for ( const [ existingId , existingDoc ] of docs ) {
if ( existingDoc . title === data . title && existingDoc . sessionId === curSession ) {
reuseId = existingId ;
break ;
}
}
}
// Second: if no title match, reuse an empty untitled doc in this session
if ( ! reuseId ) {
for ( const [ existingId , existingDoc ] of docs ) {
if ( existingDoc . sessionId === curSession &&
( ! existingDoc . title || existingDoc . title === 'Untitled' ) &&
( ! existingDoc . content || existingDoc . content . trim ( ) === '' ) ) {
reuseId = existingId ;
break ;
}
}
}
if ( reuseId ) docId = reuseId ;
}
// Capture old content before updating the map
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
const oldContent = ( docId === activeDocId && textarea ) ? textarea . value : '' ;
const isExistingDoc = docs . has ( docId ) ;
// Add or update in docs map
if ( isExistingDoc ) {
const doc = docs . get ( docId ) ;
doc . content = newContent ;
doc . version = data . version || doc . version ;
if ( data . title ) doc . title = data . title ;
if ( data . language ) doc . language = data . language ;
} else {
docs . set ( docId , {
id : docId ,
title : data . title || '' ,
language : data . language || '' ,
content : newContent ,
version : data . version || 1 ,
sessionId : sessionModule ? . getCurrentSessionId ( ) || '' ,
} ) ;
}
_syncDocIndicator ( ) ;
// Auto-title from content if still "Untitled" and AI didn't provide a title
if ( ! data . title ) autoTitleFromContent ( newContent , docId ) ;
if ( ! isOpen ) openPanel ( ) ;
// Force doc button visible (overrides appearance settings & toolbar collapse)
const toggleBtn = document . getElementById ( 'overflow-doc-btn' ) ;
if ( toggleBtn ) {
toggleBtn . style . display = '' ;
toggleBtn . classList . remove ( 'toolbar-collapsed' ) ;
toggleBtn . classList . add ( 'has-docs' ) ;
}
const docInd = document . getElementById ( 'doc-indicator-btn' ) ;
if ( docInd ) docInd . classList . add ( 'visible' ) ;
// Switch to this doc's tab
activeDocId = docId ;
const badge = document . getElementById ( 'doc-version-badge' ) ;
const titleInput = document . getElementById ( 'doc-title-input' ) ;
const langSelect = document . getElementById ( 'doc-language-select' ) ;
// Re-enable editor if it was in empty state
if ( textarea ) {
textarea . disabled = false ;
textarea . placeholder = 'Document content...' ;
}
if ( badge ) badge . textContent = ` v ${ data . version || 1 } ` ;
if ( data . title && titleInput ) titleInput . value = data . title ;
// Set language from data, or fall back to what the doc already has (e.g. from streaming)
const docLang = data . language || ( docs . has ( docId ) && docs . get ( docId ) . language ) || '' ;
if ( docLang && langSelect ) langSelect . value = docLang ;
if ( ! docLang ) attemptAutoDetect ( ) ;
const isEmailUpdate = ( docLang || '' ) . toLowerCase ( ) === 'email' ;
// Animate content update for edits; apply directly for creates/streaming
const isEdit = ! isEmailUpdate && isExistingDoc && oldContent && oldContent !== newContent && ! streamingId ;
if ( isEdit && textarea ) {
// Count changed lines to decide between animation and diff mode
const oldLines = oldContent . split ( '\n' ) ;
const newLines = newContent . split ( '\n' ) ;
let changedLines = 0 ;
const maxLen = Math . max ( oldLines . length , newLines . length ) ;
for ( let li = 0 ; li < maxLen ; li ++ ) {
if ( oldLines [ li ] !== newLines [ li ] ) changedLines ++ ;
}
if ( changedLines >= DIFF _MODE _THRESHOLD ) {
enterDiffMode ( oldContent , newContent ) ;
} else {
_animateDocEdit ( textarea , newContent ) ;
}
} else {
if ( isEmailUpdate ) {
const updatedDocForEmail = docs . get ( docId ) ;
if ( updatedDocForEmail ) {
_setMarkdownPreviewActive ( false , { remember : false } ) ;
_showEmailFields ( updatedDocForEmail ) ;
}
} else {
if ( textarea ) textarea . value = newContent ;
syncHighlighting ( ) ;
}
}
// Flash the editor wrap to indicate content was updated
const wrap = document . getElementById ( 'doc-editor-wrap' ) ;
if ( wrap && ! isEdit ) {
wrap . classList . remove ( 'doc-updated-flash' ) ;
void wrap . offsetWidth ; // force reflow
wrap . classList . add ( 'doc-updated-flash' ) ;
wrap . addEventListener ( 'animationend' , ( ) => wrap . classList . remove ( 'doc-updated-flash' ) , { once : true } ) ;
}
// Auto-detect language for docs with no language set
const updatedDoc = docs . get ( docId ) ;
if ( isEmailUpdate && updatedDoc ) {
updatedDoc . language = 'email' ;
if ( langSelect ) langSelect . value = 'email' ;
_showEmailFields ( updatedDoc ) ;
}
if ( updatedDoc && ! updatedDoc . userSetLanguage && ! updatedDoc . language ) {
setTimeout ( attemptAutoDetect , 100 ) ;
}
// Show/hide format-specific buttons and auto-toggle previews
const finalLang = docLang || ( updatedDoc && updatedDoc . language ) || '' ;
const mdToolbar = document . getElementById ( 'doc-md-toolbar' ) ;
// Toolbar shown for every doc type — items inside self-gate on language.
if ( mdToolbar ) mdToolbar . style . display = '' ;
// Auto-show table view for CSV after streaming
if ( finalLang === 'csv' ) {
requestAnimationFrame ( ( ) => {
const csvPreview = document . getElementById ( 'doc-csv-preview' ) ;
if ( csvPreview && csvPreview . style . display === 'none' ) toggleCsvPreview ( ) ;
} ) ;
}
renderTabs ( ) ;
// Refresh the header buttons (Run/Preview ▶, edit toggles) for the active
// doc after ANY update — otherwise an AI-created html/svg/code doc wouldn't
// show its ▶ Run button until the page was refreshed.
if ( docId === activeDocId ) {
_syncHeaderActions ( ) ;
// Form-backed (PDF) docs: re-fetch the rendered preview if it's showing.
if ( _isFormBackedDoc ( newContent ) ) {
const explicit = _pdfViewState . get ( docId ) ;
if ( explicit !== false ) _refreshPdfPreviewIframe ( ) ;
}
}
}
/** Toggle version history panel */
let _versionClickOutside = null ;
let _versionSavedContent = null ; // stash current content for preview/revert
async function toggleVersionHistory ( ) {
const panel = document . getElementById ( 'doc-version-panel' ) ;
if ( ! panel || ! activeDocId ) return ;
if ( panel . classList . contains ( 'hidden' ) ) {
// Stash current content so we can restore on close
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
_versionSavedContent = ta ? ta . value : null ;
// Position next to sidebar on desktop
const sidebar = document . getElementById ( 'sidebar' ) ;
const isMobile = window . innerWidth <= 768 ;
if ( ! isMobile && sidebar ) {
const sidebarRight = sidebar . classList . contains ( 'right-side' ) ;
const collapsed = document . body . classList . contains ( 'sidebar-collapsed' ) ;
if ( sidebarRight || collapsed ) {
panel . style . left = '0' ;
panel . style . right = 'auto' ;
} else {
panel . style . left = sidebar . offsetWidth + 'px' ;
panel . style . right = 'auto' ;
}
} else if ( isMobile ) {
// Clear any stale inline positioning from a prior desktop open so the
// mobile bottom-sheet (CSS) isn't pushed off-screen.
panel . style . left = '' ;
panel . style . right = '' ;
panel . style . top = '' ;
}
// Move panel to body so it's not clipped by doc pane overflow
if ( panel . parentElement !== document . body ) {
document . body . appendChild ( panel ) ;
}
panel . classList . remove ( 'hidden' ) ;
await loadVersionHistory ( ) ;
// Close on click outside
setTimeout ( ( ) => {
_versionClickOutside = ( e ) => {
if ( ! panel . contains ( e . target ) && e . target . id !== 'doc-version-badge' ) {
_closeVersionPanel ( ) ;
}
} ;
document . addEventListener ( 'click' , _versionClickOutside , true ) ;
} , 0 ) ;
} else {
_closeVersionPanel ( ) ;
}
}
function _closeVersionPanel ( ) {
const panel = document . getElementById ( 'doc-version-panel' ) ;
if ( panel ) panel . classList . add ( 'hidden' ) ;
// Restore to latest (stashed) content
if ( _versionSavedContent !== null ) {
const ta = document . getElementById ( 'doc-editor-textarea' ) ;
if ( ta ) ta . value = _versionSavedContent ;
syncHighlighting ( ) ;
_versionSavedContent = null ;
}
if ( _versionClickOutside ) {
document . removeEventListener ( 'click' , _versionClickOutside , true ) ;
_versionClickOutside = null ;
}
}
/** Build a short diff summary between two strings */
function _buildDiffSummary ( oldText , newText ) {
if ( ! oldText && ! newText ) return '' ;
const oldLines = ( oldText || '' ) . split ( '\n' ) ;
const newLines = ( newText || '' ) . split ( '\n' ) ;
const added = [ ] , removed = [ ] ;
// Simple line diff — collect changed lines
const maxCheck = Math . max ( oldLines . length , newLines . length ) ;
for ( let i = 0 ; i < maxCheck ; i ++ ) {
const ol = oldLines [ i ] , nl = newLines [ i ] ;
if ( ol === nl ) continue ;
if ( ol !== undefined && ( nl === undefined || ol !== nl ) ) removed . push ( ol . trim ( ) ) ;
if ( nl !== undefined && ( ol === undefined || ol !== nl ) ) added . push ( nl . trim ( ) ) ;
}
// Show up to 3 changes
const parts = [ ] ;
for ( const line of removed . slice ( 0 , 2 ) ) {
if ( line ) parts . push ( ` <span class="diff-del"> ${ _escHtml ( line . slice ( 0 , 60 ) ) } </span> ` ) ;
}
for ( const line of added . slice ( 0 , 2 ) ) {
if ( line ) parts . push ( ` <span class="diff-add"> ${ _escHtml ( line . slice ( 0 , 60 ) ) } </span> ` ) ;
}
const extra = ( added . length + removed . length ) - 4 ;
if ( extra > 0 ) parts . push ( ` <span>+ ${ extra } more changes</span> ` ) ;
return parts . join ( '<br>' ) ;
}
function _escHtml ( s ) {
return s . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
}
/** Load version history list */
async function loadVersionHistory ( ) {
if ( ! activeDocId ) return ;
const list = document . getElementById ( 'doc-version-list' ) ;
if ( ! list ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /versions ` ) ;
const versions = await res . json ( ) ;
// Build diff summaries between consecutive versions
const diffs = [ ] ;
for ( let i = 0 ; i < versions . length ; i ++ ) {
if ( i < versions . length - 1 ) {
diffs . push ( _buildDiffSummary ( versions [ i + 1 ] . content , versions [ i ] . content ) ) ;
} else {
diffs . push ( '' ) ;
}
}
list . innerHTML = versions . map ( ( v , i ) => `
< div class = "doc-version-item" data - version = "${v.version_number}" >
< div class = "doc-version-info" >
< span class = "doc-version-num" > v$ { v . version _number } < / s p a n >
$ { i === 0 ? '<span class="doc-version-latest">latest</span>' : ` <span class="doc-version-source"> ${ v . source } </span><span class="doc-version-time"> ${ v . created _at ? new Date ( v . created _at ) . toLocaleString ( ) : '' } </span> ` }
< / d i v >
$ { v . summary ? ` <div class="doc-version-summary"> ${ v . summary } </div> ` : '' }
$ { diffs [ i ] ? ` <div class="doc-version-diff"> ${ diffs [ i ] } </div> ` : '' }
$ { i > 0 ? ` <button class="doc-version-restore" data-version=" ${ v . version _number } ">Restore</button> ` : '' }
< / d i v >
` ).join('');
// Wire restore buttons
list . querySelectorAll ( '.doc-version-restore' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
restoreVersion ( parseInt ( btn . dataset . version ) ) ;
} ) ;
} ) ;
// Wire click to preview version + active state
list . querySelectorAll ( '.doc-version-item' ) . forEach ( item => {
item . addEventListener ( 'click' , ( e ) => {
if ( e . target . classList . contains ( 'doc-version-restore' ) ) return ;
// Toggle active state
list . querySelectorAll ( '.doc-version-item.active' ) . forEach ( el => el . classList . remove ( 'active' ) ) ;
item . classList . add ( 'active' ) ;
previewVersion ( parseInt ( item . dataset . version ) ) ;
} ) ;
} ) ;
} catch ( e ) {
list . innerHTML = '<div style="padding:8px;opacity:0.5;">Failed to load versions</div>' ;
}
}
/** Preview a specific version in the editor (without saving) */
async function previewVersion ( num ) {
if ( ! activeDocId ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /version/ ${ num } ` ) ;
const ver = await res . json ( ) ;
const textarea = document . getElementById ( 'doc-editor-textarea' ) ;
if ( textarea ) textarea . value = ver . content || '' ;
syncHighlighting ( ) ;
} catch ( e ) {
console . error ( 'Failed to preview version:' , e ) ;
}
}
/** Restore an old version (creates new version) */
async function restoreVersion ( num ) {
if ( ! activeDocId ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } /restore/ ${ num } ` , {
method : 'POST' ,
} ) ;
const doc = await res . json ( ) ;
populateEditor ( doc ) ;
// Clear stash — restored content IS the new latest
_versionSavedContent = null ;
// Update map
if ( docs . has ( activeDocId ) ) {
const d = docs . get ( activeDocId ) ;
d . content = doc . current _content || '' ;
d . version = doc . version _count || 1 ;
}
await loadVersionHistory ( ) ;
if ( uiModule ) uiModule . showToast ( ` Restored to v ${ num } ` ) ;
} catch ( e ) {
console . error ( 'Failed to restore version:' , e ) ;
if ( uiModule ) uiModule . showError ( 'Failed to restore version' ) ;
}
}
/** Update document title via PATCH */
async function updateTitle ( overrideDocId , overrideTitle ) {
const docId = overrideDocId || activeDocId ;
if ( ! docId ) return ;
const title = overrideTitle || document . getElementById ( 'doc-title-input' ) ? . value ;
if ( ! title ) return ;
try {
await fetch ( ` ${ API _BASE } /api/document/ ${ docId } ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { title } ) ,
} ) ;
if ( docs . has ( docId ) ) {
docs . get ( docId ) . title = title ;
renderTabs ( ) ;
}
} catch ( e ) {
console . error ( 'Failed to update title:' , e ) ;
}
}
/** Auto-detect title from content if still "Untitled" */
function autoTitleFromContent ( content , docId ) {
const id = docId || activeDocId ;
if ( ! id ) return ;
const doc = docs . get ( id ) ;
if ( ! doc || ( doc . title && doc . title !== '' && doc . title !== 'Untitled' ) ) return ;
const text = ( content || '' ) . trimStart ( ) ;
if ( ! text ) return ;
let title = null ;
// Markdown header: # Title
const mdMatch = text . match ( /^#{1,3}\s+(.+)/m ) ;
if ( mdMatch ) {
title = mdMatch [ 1 ] . trim ( ) ;
}
// HTML heading: <h1>Title</h1>
if ( ! title ) {
const htmlMatch = text . match ( /<h[1-3][^>]*>([^<]+)<\/h[1-3]>/i ) ;
if ( htmlMatch ) title = htmlMatch [ 1 ] . trim ( ) ;
}
// First non-empty line as fallback (only if short enough to be a title)
if ( ! title ) {
const firstLine = text . split ( '\n' ) . find ( l => l . trim ( ) . length > 0 ) ;
if ( firstLine ) {
const cleaned = firstLine . trim ( ) ;
if ( cleaned . length <= 60 && cleaned . length >= 2 ) {
title = cleaned ;
}
}
}
if ( ! title ) return ;
// Clean up: strip trailing punctuation like : or ...
title = title . replace ( /[:#*`]+$/g , '' ) . trim ( ) ;
if ( title . length > 50 ) title = title . slice ( 0 , 48 ) + '...' ;
if ( ! title ) return ;
updateTitle ( id , title ) ;
const titleInput = document . getElementById ( 'doc-title-input' ) ;
if ( titleInput && id === activeDocId ) titleInput . value = title ;
}
/** Update document language via PATCH */
async function updateLanguage ( ) {
if ( ! activeDocId ) return ;
const select = document . getElementById ( 'doc-language-select' ) ;
if ( ! select ) return ;
try {
await fetch ( ` ${ API _BASE } /api/document/ ${ activeDocId } ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { language : select . value } ) ,
} ) ;
if ( docs . has ( activeDocId ) ) {
docs . get ( activeDocId ) . language = select . value ;
renderTabs ( ) ;
}
} catch ( e ) {
console . error ( 'Failed to update language:' , e ) ;
}
}
/** Clear all tab state (e.g. on session switch) */
export function clearAll ( ) {
docs . clear ( ) ;
activeDocId = null ;
_lastSessionId = '' ;
if ( isOpen ) closePanel ( ) ;
_syncDocIndicator ( ) ;
}
export function isPanelOpen ( ) {
return isOpen ;
}
export function getCurrentDocId ( ) {
return activeDocId ;
}
/** Find an open email tab by source UID + folder. Returns docId or null. */
export function findEmailDocId ( uid , folder ) {
if ( uid == null ) return null ;
const wantUid = String ( uid ) ;
const wantFolder = ( folder || '' ) . trim ( ) ;
for ( const [ id , d ] of docs ) {
if ( d . language !== 'email' ) continue ;
const fields = _parseEmailHeader ( d . content || '' ) ;
if ( fields . sourceUid && String ( fields . sourceUid ) === wantUid &&
( ! wantFolder || ( fields . sourceFolder || '' ) . trim ( ) === wantFolder ) ) {
return id ;
}
}
return null ;
}
const documentModule = {
init ,
openPanel ,
closePanel ,
swapSide ,
createDocument ,
newDocument ,
loadDocument ,
injectFreshDoc ,
ensurePaneMounted : _ensureDocPaneMounted ,
loadSessionDocs ,
ensureDocPanel ,
saveDocument ,
handleDocUpdate ,
handleDocSuggestions ,
streamDocOpen ,
streamDocDelta ,
streamDocFinalize ,
isPanelOpen ,
enterDiffMode ,
exitDiffMode ,
isDiffModeActive ,
getCurrentDocId ,
findEmailDocId ,
getSelectionContext ,
clearSelection ,
clearAll ,
openLibrary ,
closeLibrary ,
isLibraryOpen ,
} ;
export default documentModule ;
window . documentModule = documentModule ;