1// static/js/ui.js 2 3/** 4 * UI utilities for toasts, modals, scrolling, and user feedback 5 */ 6 7import themeModule from './theme.js'; 8import * as Modals from './modalManager.js'; 9import spinnerModule from './spinner.js'; 10import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js'; 11 12let toastEl = null; 13let autoScrollEnabled = true; 14let hoveredToggleCard = null; 15let hoveredToggleWindow = null; 16let hoveredDockChip = null; 17let _lastPointerClientX = null; 18let _lastPointerClientY = null; 19 20// Smooth scroll state 21let _scrollRafId = null; 22let _scrollBox = null; 23 24function _isTextEditingTarget(target) { 25 const el = target && target.nodeType === 1 ? target : target?.parentElement; 26 return !!(el && el.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')); 27} 28 29function _targetEl(target) { 30 return target && target.nodeType === 1 ? target : target?.parentElement || null; 31} 32 33const SPACE_CARD_SELECTOR = [ 34 '#email-lib-modal .doclib-card', 35 '#doclib-modal .doclib-card', 36 '#doclib-modal .doclib-chat-row', 37 '#memory-modal .doclib-card', 38 '#tasks-modal .task-card', 39 '#tasks-modal .task-log-row', 40 '#research-overlay [data-job-id]', 41 '#cookbook-modal .doclib-card', 42 '.email-reader-tab-modal .doclib-card', 43 '.email-window-modal .doclib-card', 44].join(', '); 45 46const SPACE_BLOCKED_SELECTOR = [ 47 'button', 48 'a', 49 'input', 50 'textarea', 51 'select', 52 '[contenteditable="true"]', 53 '[contenteditable=""]', 54 '.recipient-chip', 55 '.doclib-card-dropdown', 56 '.email-card-dropdown', 57 '.task-log-row-actions', 58 '.modal-header', 59].join(', '); 60 61function _visibleModalForSpace(win) { 62 const modal = win?.closest?.('.modal[id]'); 63 if (!modal || modal.classList.contains('hidden') || modal.classList.contains('modal-minimized')) return null; 64 return modal; 65} 66 67function _isSpaceVisible(el) { 68 if (!el || !document.contains(el)) return false; 69 if (el.closest?.('.modal.hidden, .modal.modal-minimized, [hidden]')) return false; 70 return true; 71} 72 73function _spaceWindowId(win) { 74 if (!win || !document.contains(win)) return null; 75 const modal = _visibleModalForSpace(win); 76 if (modal && Modals.isRegistered(modal.id)) return modal.id; 77 if (win.closest?.('.doc-editor-pane') && Modals.isRegistered('doc-panel') && !Modals.isMinimized('doc-panel')) return 'doc-panel'; 78 return null; 79} 80 81function _windowAtPointer() { 82 if (_lastPointerClientX == null || _lastPointerClientY == null) return null; 83 const x = _lastPointerClientX; 84 const y = _lastPointerClientY; 85 const candidates = [ 86 ...document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized) .modal-content'), 87 ...document.querySelectorAll('.doc-editor-pane'), 88 ].filter(el => { 89 if (!document.contains(el)) return false; 90 const r = el.getBoundingClientRect(); 91 return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; 92 }); 93 if (!candidates.length) return null; 94 return candidates.reduce((top, el) => { 95 const mz = parseInt(getComputedStyle(el.closest('.modal') || el).zIndex, 10) || 0; 96 const tz = parseInt(getComputedStyle(top.closest('.modal') || top).zIndex, 10) || 0; 97 return mz >= tz ? el : top; 98 }); 99} 100 101function _containsPointer(el) { 102 if (!el || _lastPointerClientX == null || _lastPointerClientY == null) return false; 103 const r = el.getBoundingClientRect(); 104 return _lastPointerClientX >= r.left && _lastPointerClientX <= r.right 105 && _lastPointerClientY >= r.top && _lastPointerClientY <= r.bottom; 106} 107 108function _closeHoveredWindow() { 109 let win = _windowAtPointer(); 110 if (!win) { 111 try { 112 const underPointer = document.elementFromPoint(_lastPointerClientX, _lastPointerClientY); 113 win = underPointer?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane') || null; 114 } catch {} 115 } 116 if (!win) win = hoveredToggleWindow; 117 if (!win || !document.contains(win)) return false; 118 const modalForWin = win.closest?.('.modal[id]'); 119 if (modalForWin?.id === 'email-lib-modal') { 120 const closeBtn = document.getElementById('email-lib-close') || modalForWin.querySelector('.close-btn'); 121 if (closeBtn) {
122 try { closeBtn.click(); return true; } catch {} 123 } 124 try { modalForWin.remove(); return true; } catch {} 125 } 126 const id = _spaceWindowId(win); 127 if (id && Modals.isRegistered(id)) { 128 Modals.close(id); 129 return true; 130 } 131 const modal = _visibleModalForSpace(win); 132 if (!modal) return false; 133 const closeBtn = modal.querySelector('.close-btn, .modal-close, .modal-close-btn, [data-action="close"]'); 134 if (closeBtn) { 135 try { closeBtn.click(); return true; } catch {} 136 } 137 try { modal.classList.add('hidden'); return true; } catch {} 138 return false; 139} 140 141function _spaceIsBlocked(e, surface) { 142 const target = _targetEl(e.target); 143 if (!target) return false; 144 if (_isTextEditingTarget(target)) return !surface || surface.contains(target); 145 const blocked = target.closest?.(SPACE_BLOCKED_SELECTOR); 146 return !!(blocked && (!surface || surface.contains(blocked))); 147} 148 149function _activateSpaceCard(card) { 150 if (!card || !document.contains(card)) return false; 151 if (card.matches('#tasks-modal .task-card')) { 152 const titleRow = card.querySelector('.memory-item-title')?.closest('div'); 153 if (titleRow) { 154 titleRow.click(); 155 return true; 156 } 157 } 158 card.dataset.spaceToggle = '1'; 159 card.click(); 160 setTimeout(() => { 161 try { delete card.dataset.spaceToggle; } catch {} 162 }, 0); 163 return true; 164} 165 166function _initHoverCardSpaceToggle() { 167 if (document._odysseusHoverCardSpaceToggle) return; 168 document._odysseusHoverCardSpaceToggle = true; 169 document.addEventListener('pointerover', (e) => { 170 _lastPointerClientX = e.clientX; 171 _lastPointerClientY = e.clientY; 172 const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]'); 173 if (chip) hoveredDockChip = chip; 174 const card = e.target?.closest?.(SPACE_CARD_SELECTOR); 175 if (card) hoveredToggleCard = card; 176 const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane'); 177 if (win) hoveredToggleWindow = win; 178 }, true); 179 document.addEventListener('pointermove', (e) => { 180 _lastPointerClientX = e.clientX; 181 _lastPointerClientY = e.clientY; 182 }, true); 183 document.addEventListener('pointerout', (e) => { 184 const next = e.relatedTarget; 185 if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null; 186 if (hoveredToggleCard && (!next || !hoveredToggleCard.contains(next))) hoveredToggleCard = null; 187 if (hoveredToggleWindow && (!next || !hoveredToggleWindow.contains(next))) hoveredToggleWindow = null; 188 }, true); 189 document.addEventListener('keydown', (e) => { 190 if (e.code !== 'Space' || e.repeat) return; 191 if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) { 192 if (_spaceIsBlocked(e, hoveredToggleCard)) return; 193 e.preventDefault(); 194 _activateSpaceCard(hoveredToggleCard); 195 return; 196 } 197 if (hoveredDockChip && document.contains(hoveredDockChip)) { 198 if (_spaceIsBlocked(e, hoveredDockChip)) return; 199 const id = hoveredDockChip.dataset.modalId; 200 if (id && Modals.isRegistered(id)) { 201 e.preventDefault(); 202 Modals.restore(id); 203 } 204 return; 205 } 206 const id = _spaceWindowId(hoveredToggleWindow); 207 if (!id) return; 208 if (_spaceIsBlocked(e, hoveredToggleWindow)) return; 209 e.preventDefault(); 210 Modals.minimize(id); 211 }, true); 212} 213 214_initHoverCardSpaceToggle(); 215 216/** 217 * Copy text to clipboard 218 */ 219export async function copyToClipboard(text) { 220 try { 221 await navigator.clipboard.writeText(text); 222 showToast('Copied'); 223 } 224 catch { 225 const ta = document.createElement('textarea'); 226 ta.value = text; 227 ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'; 228 document.body.appendChild(ta); 229 ta.select(); 230 document.execCommand('copy'); 231 document.body.removeChild(ta); 232 showToast('Copied'); 233 } 234} 235 236// Wire swipe-to-dismiss on the shared toast element. Runs once, the first 237// time a toast is shown. Tracks horizontal touch drag; if the user drags 238// more than DISMISS_PX, the toast slides off in the drag direction and 239// hides early. Anything less snaps back. Desktop unaffected (touch 240// listeners only fire from a touchscreen â mouse is handled by the 241// existing à button and auto-hide timer). 242function _wireToastSwipe(el) { 243 if (!el || el._swipeWired) return; 244 el._swipeWired = true; 245 const DISMISS_PX = 70; 246 let startX = 0, currentX = 0, swiping = false;
247 el.addEventListener('touchstart', (e) => { 248 if (!el.classList.contains('show')) return; 249 const t = e.touches[0]; 250 if (!t) return; 251 startX = t.clientX; 252 currentX = t.clientX; 253 swiping = true; 254 // Kill the slide-in transition mid-flight so the touch tracks the 255 // finger 1:1 instead of fighting a still-running animation. 256 el.style.transition = 'none'; 257 }, { passive: true }); 258 el.addEventListener('touchmove', (e) => { 259 if (!swiping) return; 260 const t = e.touches[0]; 261 if (!t) return; 262 currentX = t.clientX; 263 const dx = currentX - startX; 264 el.style.transform = `translateX(${dx}px)`; 265 // Fade as the toast leaves the rest position â visual cue for 266 // approaching the dismiss threshold. 267 el.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 200)); 268 }, { passive: true }); 269 const endSwipe = () => { 270 if (!swiping) return; 271 swiping = false; 272 const dx = currentX - startX; 273 // Restore the transition so the next mutation animates. 274 el.style.transition = ''; 275 if (Math.abs(dx) > DISMISS_PX) { 276 // Fling off in the drag direction, then hide. 277 el.style.transform = `translateX(${dx > 0 ? '120%' : '-120%'})`; 278 el.style.opacity = '0'; 279 clearTimeout(el._hideTimer); 280 setTimeout(() => { 281 el.classList.remove('show'); 282 el.classList.add('exiting'); 283 el.style.transform = ''; 284 el.style.opacity = ''; 285 }, 180); 286 } else { 287 // Snap back to rest. 288 el.style.transform = ''; 289 el.style.opacity = ''; 290 } 291 }; 292 el.addEventListener('touchend', endSwipe); 293 el.addEventListener('touchcancel', endSwipe); 294} 295 296/** 297 * Show success toast message 298 */ 299export function showToast(msg, durationOrOpts) { 300 if (!toastEl) { 301 toastEl = document.getElementById('toast'); 302 } 303 _wireToastSwipe(toastEl); 304 toastEl.textContent = ''; 305 toastEl.classList.remove('error'); 306 307 let duration = 1200, actionLabel = null, onAction = null, actionHint = null, actionIcon = null, leadingIcon = null; 308 if (typeof durationOrOpts === 'object' && durationOrOpts) { 309 duration = durationOrOpts.duration || 5000; 310 actionLabel = durationOrOpts.action; 311 onAction = durationOrOpts.onAction; 312 actionHint = durationOrOpts.actionHint || null; 313 actionIcon = durationOrOpts.actionIcon || null; 314 leadingIcon = durationOrOpts.leadingIcon || null; 315 } else if (typeof durationOrOpts === 'number') { 316 duration = durationOrOpts; 317 } 318 319 const textSpan = document.createElement('span'); 320 if (leadingIcon === 'check') { 321 const icon = document.createElement('span'); 322 icon.className = 'toast-checkmark'; 323 icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>'; 324 toastEl.appendChild(icon); 325 } else if (leadingIcon === 'spinner') { 326 const wp = spinnerModule.createWhirlpool(14); 327 const icon = wp.element; 328 icon.classList.add('toast-whirlpool'); 329 icon.style.cssText = 'width:14px;height:14px;margin:0 8px 0 0;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;'; 330 toastEl.appendChild(icon); 331 } 332 textSpan.textContent = msg; 333 toastEl.appendChild(textSpan); 334 335 if (actionLabel && onAction) { 336 // Wrap the action in a small column so we can stack a Ctrl-Z-style hint 337 // directly under the button. 338 const stack = document.createElement('span'); 339 stack.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:1px;margin-left:10px;line-height:1;'; 340 341 const btn = document.createElement('button'); 342 // If the caller supplied an SVG icon, prepend it. We trust the icon string 343 // (only set internally) â never accept caller-controlled HTML otherwise. 344 if (actionIcon) { 345 btn.innerHTML = `<span style="display:inline-flex;align-items:center;gap:5px;">${actionIcon}<span></span></span>`; 346 btn.querySelector('span span').textContent = actionLabel; 347 } else { 348 btn.textContent = actionLabel; 349 } 350 // The toast itself is `pointer-events: none` so it doesn't block clicks 351 // beneath it. With an action button we need to flip both the toast AND 352 // the button so the user can actually click Undo. The flag is reset on 353 // the next plain showToast / showError call (those overwrite textContent 354 // which strips the button + we clear inline style at the top below). 355 btn.style.cssText = 'padding:2px 10px;border:1px solid var(--fg);border-radius:4px;background:none;color:var(--fg);cursor:pointer;font-size:12px;pointer-events:auto;display:inline-flex;align-items:center;'; 356 btn.addEventListener('click', (e) => { 357 e.stopPropagation(); 358 e.preventDefault(); 359 toastEl.classList.remove('show'); 360 onAction(); 361 }); 362 stack.appendChild(btn); 363 364 // Keyboard-shortcut hints (Ctrl+Z / âZ) are meaningless on touch devices â 365 // skip them on mobile so the toast just shows the Undo button. 366 if (actionHint && window.innerWidth > 768) { 367 const hint = document.createElement('span'); 368 hint.textContent = actionHint; 369 hint.style.cssText = 'font-size:9px;opacity:0.55;letter-spacing:0.4px;text-transform:uppercase;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin-top:1px;pointer-events:none;'; 370 stack.appendChild(hint); 371 } 372 373 toastEl.appendChild(stack); 374 375 // Small à to dismiss the toast without taking the action. Useful when 376 // the user already acted (or just doesn't want the banner sitting there). 377 const closeBtn = document.createElement('button'); 378 closeBtn.type = 'button'; 379 closeBtn.setAttribute('aria-label', 'Dismiss'); 380 closeBtn.title = 'Dismiss'; 381 closeBtn.textContent = 'Ã'; 382 closeBtn.style.cssText = 'margin-left:8px;padding:0;width:20px;height:20px;line-height:1;border:none;background:none;color:var(--fg);opacity:0.55;cursor:pointer;font-size:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;pointer-events:auto;'; 383 closeBtn.addEventListener('mouseenter', () => { closeBtn.style.opacity = '1'; }); 384 closeBtn.addEventListener('mouseleave', () => { closeBtn.style.opacity = '0.55'; }); 385 closeBtn.addEventListener('click', (e) => { 386 e.stopPropagation(); 387 e.preventDefault(); 388 clearTimeout(toastEl._hideTimer); 389 toastEl.classList.add('exiting'); 390 toastEl.classList.remove('show'); 391 }); 392 toastEl.appendChild(closeBtn); 393 394 toastEl.style.pointerEvents = 'auto'; 395 } else { 396 // No action â restore the default non-blocking behavior. 397 toastEl.style.pointerEvents = ''; 398 } 399 400 // Pin to top-right via CSS â clear any legacy inline overrides so the 401 // slide-in-from-right / slide-out-to-left transition can run cleanly. 402 toastEl.style.left = ''; 403 toastEl.style.transform = ''; 404 toastEl.classList.remove('exiting'); 405 toastEl.classList.add('show'); 406 clearTimeout(toastEl._hideTimer); 407 toastEl._hideTimer = setTimeout(() => { 408 // Add `exiting` so the CSS rule slides it off to the LEFT instead of 409 // back to the right (where it came from). We piggyback on the same 410 // .toast base; .exiting overrides the resting transform. 411 toastEl.classList.add('exiting'); 412 toastEl.classList.remove('show'); 413 // Reset pointer-events so an action-toast (which sets it to 'auto' 414 // for its clickable button) doesn't leave the toast intercepting 415 // clicks after it's slid away. Was previously only cleared on the 416 // NEXT plain toast, so a lingering action-toast could appear to 417 // "lock" interaction near the top-right. 418 toastEl.style.pointerEvents = ''; 419 }, duration); 420} 421 422/** 423 * Show error toast message 424 */ 425export function showError(msg) { 426 if (!toastEl) { 427 toastEl = document.getElementById('toast'); 428 } 429 _wireToastSwipe(toastEl); 430 toastEl.textContent = msg; 431 toastEl.classList.add('error'); 432 toastEl.style.left = ''; 433 toastEl.style.transform = ''; 434 toastEl.classList.remove('exiting'); 435 toastEl.classList.add('show'); 436 clearTimeout(toastEl._hideTimer); 437 toastEl._hideTimer = setTimeout(() => { 438 toastEl.classList.add('exiting'); 439 toastEl.classList.remove('show'); 440 }, 3000); 441} 442 443/** 444 * Smooth-scroll chat history to bottom using rAF lerp. 445 * Throttled during streaming so it doesn't fight user scrolling. 446 */ 447let _scrollThrottleTimer = null; 448export function scrollHistory() { 449 if (!autoScrollEnabled) return; 450 if (!_scrollBox) { 451 _scrollBox = document.getElementById('chat-history'); 452 }
453 // Throttle: only start a new scroll animation every 500ms 454 if (_scrollThrottleTimer) return; 455 _scrollThrottleTimer = setTimeout(() => { _scrollThrottleTimer = null; }, 500); 456 if (!_scrollRafId) { 457 _scrollRafId = requestAnimationFrame(_smoothScrollStep); 458 } 459} 460 461function _smoothScrollStep() { 462 const box = _scrollBox; 463 if (!box || !autoScrollEnabled) { 464 _scrollRafId = null; 465 return; 466 } 467 const target = box.scrollHeight - box.clientHeight; 468 const current = box.scrollTop; 469 const diff = target - current; 470 471 // If user scrolled up significantly, don't force them down 472 if (diff > 300) { 473 _scrollRafId = null; 474 return; 475 } 476 477 if (diff <= 1) { 478 box.scrollTop = target; 479 _scrollRafId = null; 480 return; 481 } 482 483 // Lerp: gentle catch-up 484 const factor = window.innerWidth <= 768 ? 0.4 : 0.2; 485 box.scrollTop = current + diff * factor; 486 _scrollRafId = requestAnimationFrame(_smoothScrollStep); 487} 488 489/** 490 * Instant scroll to bottom â use for non-streaming contexts 491 * like loading history or switching sessions. 492 */ 493export function scrollHistoryInstant() { 494 if (!_scrollBox) { 495 _scrollBox = document.getElementById('chat-history'); 496 } 497 if (_scrollBox) { 498 _scrollBox.scrollTop = _scrollBox.scrollHeight; 499 } 500} 501 502/** 503 * Enable/disable auto-scroll 504 */ 505export function setAutoScroll(enabled) { 506 autoScrollEnabled = enabled; 507} 508 509/** 510 * Get auto-scroll state 511 */ 512export function getAutoScroll() { 513 return autoScrollEnabled; 514} 515 516/** 517 * Auto-resize textarea based on content 518 */ 519export function autoResize(textarea) { 520 const lineHeight = parseInt(getComputedStyle(textarea).lineHeight); 521 const isMobile = window.innerWidth <= 768; 522 const maxHeight = isMobile ? 150 : lineHeight * 8; 523 524 // Use a hidden clone to measure without disrupting the real textarea 525 let clone = textarea._resizeClone; 526 if (!clone) { 527 clone = textarea.cloneNode(false); 528 clone.style.cssText = getComputedStyle(textarea).cssText; 529 clone.style.position = 'absolute'; 530 clone.style.visibility = 'hidden'; 531 clone.style.height = '0'; 532 clone.style.transition = 'none'; 533 clone.style.overflow = 'hidden'; 534 clone.style.pointerEvents = 'none'; 535 clone.style.zIndex = '-1'; 536 textarea.parentNode.appendChild(clone); 537 textarea._resizeClone = clone; 538 } 539 clone.style.width = textarea.offsetWidth + 'px'; 540 clone.value = textarea.value; 541 clone.style.height = '0'; 542 const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight); 543 textarea.style.height = newHeight + 'px'; 544 textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden'; 545} 546 547/** 548 * Debounce function for performance 549 */ 550export function debounce(func, wait) { 551 let timeout; 552 return function(...args) { 553 const later = () => { 554 timeout = null; 555 func.apply(this, args); 556 }; 557 clearTimeout(timeout); 558 timeout = setTimeout(later, wait); 559 }; 560} 561 562/** 563 * Get element by ID (utility helper) 564 */ 565export function el(id) { 566 return document.getElementById(id); 567} 568 569/** 570 * Styled confirm dialog â replaces native browser confirm(). 571 * Returns a Promise<boolean>. 572 */ 573export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) { 574 return new Promise(resolve => { 575 // Reuse or create the modal 576 let overlay = document.getElementById('styled-confirm-overlay'); 577 if (!overlay) { 578 overlay = document.createElement('div'); 579 overlay.id = 'styled-confirm-overlay'; 580 overlay.className = 'modal'; 581 overlay.innerHTML = 582 '<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="styled-confirm-title" aria-describedby="styled-confirm-msg">' + 583 '<div class="modal-header"><h4 id="styled-confirm-title">Confirm</h4></div>' + 584 '<div class="modal-body"><p id="styled-confirm-msg"></p></div>' + 585 '<div class="modal-footer">' + 586 '<button id="styled-confirm-cancel"></button>' + 587 '<button id="styled-confirm-ok"></button>' + 588 '</div>' + 589 '</div>'; 590 document.body.appendChild(overlay); 591 } 592 593 const msgEl = document.getElementById('styled-confirm-msg'); 594 const okBtn = document.getElementById('styled-confirm-ok'); 595 const cancelBtn = document.getElementById('styled-confirm-cancel'); 596 597 msgEl.textContent = message; 598 okBtn.textContent = confirmText; 599 cancelBtn.textContent = cancelText; 600 okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary'; 601 cancelBtn.className = 'confirm-btn confirm-btn-secondary'; 602
603 // Remember what had focus so we can restore it when the dialog closes. 604 const _prevFocus = document.activeElement; 605 overlay.classList.remove('hidden'); 606 overlay.style.display = ''; 607 608 function cleanup(result) { 609 overlay.classList.add('hidden'); 610 overlay.style.display = 'none'; 611 okBtn.removeEventListener('click', onOk); 612 cancelBtn.removeEventListener('click', onCancel); 613 overlay.removeEventListener('click', onBackdrop); 614 document.removeEventListener('keydown', onKey); 615 try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {} 616 resolve(result); 617 } 618 function onOk() { cleanup(true); } 619 function onCancel() { cleanup(false); } 620 function onBackdrop(e) { if (e.target === overlay) cleanup(false); } 621 function onKey(e) { 622 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { 623 e.preventDefault(); 624 const active = document.activeElement; 625 if (active === okBtn) cancelBtn.focus(); 626 else okBtn.focus(); 627 } else if (e.key === 'Escape') { 628 e.preventDefault(); 629 e.stopPropagation(); 630 e.stopImmediatePropagation(); 631 cleanup(false); 632 } else if (e.key === 'Tab') { 633 // Trap focus inside the dialog so Tab can't wander to the page behind. 634 e.preventDefault(); 635 const f = [cancelBtn, okBtn]; 636 const i = f.indexOf(document.activeElement); 637 const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1); 638 f[n].focus(); 639 } 640 } 641 642 okBtn.addEventListener('click', onOk); 643 cancelBtn.addEventListener('click', onCancel); 644 overlay.addEventListener('click', onBackdrop); 645 document.addEventListener('keydown', onKey); 646 okBtn.focus(); 647 }); 648} 649 650/** 651 * Styled text-input prompt â drop-in replacement for window.prompt(). 652 * Resolves to the trimmed string the user typed, or null on Cancel / Escape / backdrop. 653 */ 654export function styledPrompt(message, { 655 title = 'Name', 656 defaultValue = '', 657 placeholder = '', 658 confirmText = 'Save', 659 cancelText = 'Cancel', 660 maxLength = 80, 661} = {}) { 662 return new Promise(resolve => { 663 let overlay = document.getElementById('styled-prompt-overlay'); 664 if (!overlay) { 665 overlay = document.createElement('div'); 666 overlay.id = 'styled-prompt-overlay'; 667 overlay.className = 'modal'; 668 overlay.innerHTML = 669 '<div class="modal-content styled-confirm-box styled-prompt-box" role="dialog" aria-modal="true" aria-labelledby="styled-prompt-title" aria-describedby="styled-prompt-msg">' + 670 '<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' + 671 '<div class="modal-body">' + 672 '<p id="styled-prompt-msg"></p>' + 673 '<input type="text" id="styled-prompt-input" class="styled-prompt-input" />' + 674 '</div>' + 675 '<div class="modal-footer">' + 676 '<button id="styled-prompt-cancel" class="confirm-btn confirm-btn-secondary"></button>' + 677 '<button id="styled-prompt-ok" class="confirm-btn confirm-btn-primary"></button>' + 678 '</div>' + 679 '</div>'; 680 document.body.appendChild(overlay); 681 } 682 683 const titleEl = document.getElementById('styled-prompt-title'); 684 const msgEl = document.getElementById('styled-prompt-msg'); 685 const input = document.getElementById('styled-prompt-input'); 686 const okBtn = document.getElementById('styled-prompt-ok'); 687 const cancelBtn = document.getElementById('styled-prompt-cancel'); 688 689 titleEl.textContent = title; 690 msgEl.textContent = message || ''; 691 msgEl.style.display = message ? '' : 'none'; 692 input.value = defaultValue || ''; 693 input.placeholder = placeholder || ''; 694 input.maxLength = maxLength; 695 okBtn.textContent = confirmText; 696 cancelBtn.textContent = cancelText; 697 698 // Remember what had focus so we can restore it when the dialog closes. 699 const _prevFocus = document.activeElement; 700 overlay.classList.remove('hidden'); 701 overlay.style.display = ''; 702 703 function cleanup(result) { 704 overlay.classList.add('hidden'); 705 overlay.style.display = 'none'; 706 okBtn.removeEventListener('click', onOk); 707 cancelBtn.removeEventListener('click', onCancel); 708 overlay.removeEventListener('click', onBackdrop); 709 document.removeEventListener('keydown', onKey); 710 input.removeEventListener('keydown', onInputKey);
711 try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {} 712 resolve(result); 713 } 714 function onOk() { cleanup((input.value || '').trim()); } 715 function onCancel() { cleanup(null); } 716 function onBackdrop(e) { if (e.target === overlay) cleanup(null); } 717 function onKey(e) { 718 if (e.key === 'Escape') { 719 e.preventDefault(); 720 e.stopPropagation(); 721 e.stopImmediatePropagation(); 722 cleanup(null); 723 } else if (e.key === 'Tab') { 724 // Trap focus inside the dialog (input â Cancel â OK â input â¦). 725 e.preventDefault(); 726 const f = [input, cancelBtn, okBtn]; 727 const i = f.indexOf(document.activeElement); 728 const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1); 729 f[n].focus(); 730 } 731 } 732 function onInputKey(e) { 733 if (e.key === 'Enter') { 734 e.preventDefault(); 735 onOk(); 736 } 737 } 738 739 okBtn.addEventListener('click', onOk); 740 cancelBtn.addEventListener('click', onCancel); 741 overlay.addEventListener('click', onBackdrop); 742 document.addEventListener('keydown', onKey); 743 input.addEventListener('keydown', onInputKey); 744 745 requestAnimationFrame(() => { 746 input.focus(); 747 input.select(); 748 }); 749 }); 750} 751 752// Lookup table for esc(); hoisted out of the replace callback so it is 753// allocated once rather than per matched character. 754const _ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 755/** 756 * HTML-escape a string to prevent XSS. 757 * Canonical implementation â other modules should use uiModule.esc() instead of local copies. 758 */ 759export function esc(s) { 760 return (s || '').replace(/[&<>"']/g, (m) => _ESC_MAP[m]); 761} 762 763// ââ Mobile: suppress synthetic click/mousedown on backdrop ââ 764// When a touch starts inside .modal-content, set a flag so that 765// synthetic mouse events on the backdrop are ignored. 766let _touchInsideModal = false; 767if ('ontouchstart' in window) { 768 document.addEventListener('touchstart', (e) => { 769 if (e.target.closest('.modal-content')) { 770 _touchInsideModal = true; 771 } 772 }, { passive: true }); 773 document.addEventListener('touchend', () => {
774 // Clear after a short delay â synthetic click fires ~300ms after touchend 775 setTimeout(() => { _touchInsideModal = false; }, 400); 776 }, { passive: true }); 777} 778 779/** 780 * Check if a backdrop dismiss should be suppressed on mobile. 781 * Other modules can call this to guard their own backdrop handlers. 782 */ 783export function isTouchInsideModal() { 784 return _touchInsideModal; 785} 786 787// Close floating dropdowns/popups on scroll to prevent them drifting 788function _initScrollDismiss() { 789 const chatHistory = document.getElementById('chat-history'); 790 if (chatHistory) { 791 chatHistory.addEventListener('scroll', () => { 792 chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show')); 793 document.querySelectorAll('.ctx-popup').forEach(dismissOrRemove); 794 }, { passive: true }); 795 } else { 796 // Retry once if element doesn't exist yet 797 setTimeout(_initScrollDismiss, 500); 798 } 799} 800if (document.readyState === 'loading') { 801 document.addEventListener('DOMContentLoaded', _initScrollDismiss); 802} else { 803 _initScrollDismiss(); 804} 805 806/** 807 * Returns the SVG string for an empty-state icon. `kind` is one of 808 * 'smiley' | 'sad' | 'neutral'. The returned <svg> has NO inline style â 809 * callers wrap with `<span style="vertical-align:-3px;margin-left:6px;">â¦</span>` 810 * (or similar) to keep the per-site visual nudge they need. 811 */ 812export function emptyStateIcon(kind) { 813 const SVG_OPEN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'; 814 const SVG_CLOSE = '</svg>'; 815 let inner; 816 switch (kind) { 817 case 'sad': 818 inner = '<circle cx="12" cy="12" r="10"/><path d="M16 16s-1.5-2-4-2-4 2-4 2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>'; 819 break; 820 case 'neutral': 821 inner = '<circle cx="12" cy="12" r="10"/><line x1="8" y1="15" x2="16" y2="15"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>'; 822 break; 823 case 'smiley': 824 default: 825 inner = '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>'; 826 break; 827 } 828 return SVG_OPEN + inner + SVG_CLOSE; 829} 830 831const uiModule = { 832 copyToClipboard, 833 showToast, 834 showError, 835 styledConfirm, 836 styledPrompt, 837 scrollHistory, 838 scrollHistoryInstant, 839 setAutoScroll, 840 getAutoScroll, 841 autoResize, 842 debounce, 843 el, 844 esc, 845 isTouchInsideModal, 846 emptyStateIcon, 847 registerMenuDismiss 848}; 849 850export default uiModule; 851 852// Expose the styled confirm globally so any module can replace the native 853// browser confirm() with the themed dialog â even files that don't import 854// uiModule. Usage: `if (!await window.styledConfirm(msg, { danger:true })) return;` 855if (typeof window !== 'undefined') { 856 window.styledConfirm = styledConfirm; 857} 858 859// ââ Mobile: clear enter animation so inline transform works for dragging ââ 860// The CSS `animation: sheet-enter ... forwards` holds the final transform, 861// blocking any inline style changes. We clear it once the animation completes. 862if ('ontouchstart' in window || window.innerWidth <= 768) { 863 document.addEventListener('animationend', (e) => { 864 if (e.animationName === 'sheet-enter' && 865 (e.target.classList.contains('modal-content') || e.target.id === 'theme-popup')) { 866 e.target.classList.add('sheet-ready'); 867 } 868 }); 869 // When a modal is re-shown, remove sheet-ready so the enter animation plays again 870 new MutationObserver((mutations) => { 871 for (const m of mutations) { 872 if (m.type === 'attributes' && m.attributeName === 'class') { 873 const modal = m.target; 874 if (modal.classList.contains('modal') && !modal.classList.contains('hidden')) { 875 const content = modal.querySelector('.modal-content') || modal.querySelector('#theme-popup'); 876 if (content) { 877 content.classList.remove('sheet-ready', 'modal-closing'); 878 } 879 } 880 } 881 } 882 }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] }); 883} 884 885// ââ Mobile swipe-down-to-dismiss for bottom sheet modals ââ 886// Finger-following drag with velocity-based dismiss. 887// Works from grab handle, header, OR anywhere on the sheet when content is scrolled to top. 888if ('ontouchstart' in window) { 889 const DISMISS_THRESHOLD = 50; // px â dismiss if dragged past this 890 const VELOCITY_THRESHOLD = 0.3; // px/ms â fast flick dismisses even below threshold 891 const RUBBER_RESISTANCE = 0.35; // drag resistance when pulling up past origin 892 893 let _swipeTarget = null; 894 let _startY = 0, _startX = 0; 895 let _lastY = 0, _lastT = 0; 896 let _velocity = 0; 897 let _dragging = false; // true once we've committed to a vertical drag 898 let _cancelled = false; // true if horizontal movement detected 899 900 // Close any floating dropdowns/menus that hang off body via position:fixed. 901 // Called when a swipe-dismiss gesture starts so the menu doesn't orphan over 902 // the page after the sheet slides away. 903 function _closeFloatingDropdownsForSwipe() { 904 document.querySelectorAll( 905 '.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu' 906 ).forEach(d => { 907 if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active'); 908 // Registered menus tear down through their own dismiss (releasing the 909 // Escape-stack entry);
909 unregistered ones (email/dep) just get removed. 910 dismissOrRemove(d); 911 }); 912 } 913 914 document.addEventListener('touchstart', (e) => { 915 // Match .modal-content or #theme-popup (which acts as modal-content but uses its own ID) 916 const content = e.target.closest('.modal-content') || e.target.closest('#theme-popup'); 917 if (!content) return; 918 919 // The image editor owns all touches inside its container so the user 920 // can paint / move layers / draw selections without the modal trying 921 // to interpret it as a swipe-to-dismiss gesture. Skip the swipe init 922 // entirely when the touch starts inside the editor area. 923 if (e.target.closest('.gallery-editor, .gallery-editor-container')) return; 924 // Internal vertical drag handles (e.g. the calendar's cal-splitter that 925 // resizes the day-detail pane) consume vertical touches themselves. If 926 // we don't bail here, the swipe-dismiss path also tracks the touch and 927 // slides the whole modal down as the user drags the handle. The 928 // [data-no-swipe-dismiss] hook lets other components opt out the same 929 // way without having to hard-code their selector here. 930 if (e.target.closest('.cal-splitter, [data-no-swipe-dismiss]')) return; 931 932 // Only allow swipe-dismiss from header or grab handle (top 48px) 933 const isHeader = !!e.target.closest('.modal-header'); 934 const isButton = !!e.target.closest('button, input, select, label'); 935 if (isHeader && isButton) return; // let button clicks through 936 const touch = e.touches[0]; 937 const contentRect = content.getBoundingClientRect(); 938 const isGrabZone = (touch.clientY - contentRect.top) < 48; 939 // Also allow swipe-dismiss from anywhere on the sheet when it's already 940 // scrolled to the top â feels natural and matches iOS bottom-sheet UX. 941 const isAtScrollTop = content.scrollTop <= 0; 942 943 if (!isHeader && !isGrabZone && !isAtScrollTop) return; // body touches â let native scroll handle it 944 945 _swipeTarget = content; 946 // Ensure CSS animation is cleared so inline transform works 947 content.classList.add('sheet-ready');
948 content.style.animation = 'none'; 949 _startY = touch.clientY; 950 _startX = touch.clientX; 951 _lastY = _startY; 952 _lastT = e.timeStamp; 953 _velocity = 0; 954 _dragging = false; 955 _cancelled = false; 956 }, { passive: true }); 957 958 document.addEventListener('touchmove', (e) => { 959 if (!_swipeTarget || _cancelled) return; 960 const touch = e.touches[0]; 961 const dx = Math.abs(touch.clientX - _startX); 962 const dy = touch.clientY - _startY; 963 964 // First few pixels: decide if this is horizontal scroll or content scroll 965 if (!_dragging) { 966 if (dx > 40 && dx > Math.abs(dy) * 2) { 967 _swipeTarget.style.transform = ''; 968 _swipeTarget = null; 969 _cancelled = true; 970 return; 971 } 972 if (Math.abs(dy) > 8) { 973 // Find the nearest scrollable ancestor of the touch point 974 let scrollEl = e.target; 975 while (scrollEl && scrollEl !== _swipeTarget) { 976 if (scrollEl.scrollHeight > scrollEl.clientHeight + 1) { 977 const ov = getComputedStyle(scrollEl).overflowY; 978 if (ov === 'auto' || ov === 'scroll') break; 979 } 980 scrollEl = scrollEl.parentElement; 981 } 982 const hasScroller = scrollEl && scrollEl !== _swipeTarget; 983 // If touch is inside a scrollable child, let native scroll handle it 984 if (hasScroller) { 985 _swipeTarget.style.transform = ''; 986 _swipeTarget = null; 987 _cancelled = true; 988 return; 989 } 990 // If swiping up and modal-content itself is scrollable, let native handle it 991 if (dy < 0 && _swipeTarget.scrollHeight > _swipeTarget.clientHeight + 1) { 992 _swipeTarget.style.transform = ''; 993 _swipeTarget = null; 994 _cancelled = true; 995 return; 996 } 997 // If swiping down but content isn't at the top, let native scroll 998 if (dy > 0 && _swipeTarget.scrollTop > 0) { 999 _swipeTarget.style.transform = ''; 1000 _swipeTarget = null; 1001 _cancelled = true; 1002 return; 1003 } 1004 _dragging = true; 1005 _swipeTarget.style.transition = 'none'; 1006 _swipeTarget.style.willChange = 'transform'; 1007 // A swipe is starting â close any floating menus/dropdowns so they 1008 // don't orphan over the page once the sheet slides away. Covers the 1009 // email reader More menu, cookbook serve kebab + saved-configs, and 1010 // anything else hanging off body via _anchor. 1011 _closeFloatingDropdownsForSwipe(); 1012 } else { 1013 return; 1014 } 1015 } 1016 1017 // Track velocity (exponential moving average) 1018 const dt = e.timeStamp - _lastT; 1019 if (dt > 0) { 1020 const instantV = (touch.clientY - _lastY) / dt; 1021 _velocity = _velocity * 0.6 + instantV * 0.4; 1022 } 1023 _lastY = touch.clientY; 1024 _lastT = e.timeStamp; 1025 1026 e.preventDefault(); 1027 if (dy > 0) { 1028 _swipeTarget.style.transform = `translateY(${dy}px)`; 1029 } else { 1030 const rubberDy = dy * RUBBER_RESISTANCE; 1031 _swipeTarget.style.transform = `translateY(${rubberDy}px)`; 1032 } 1033 }, { passive: false }); 1034 1035 document.addEventListener('touchend', (e) => { 1036 if (!_swipeTarget || !_dragging) { 1037 _swipeTarget = null; 1038 return; 1039 } 1040 const el = _swipeTarget; 1041 _swipeTarget = null; 1042 1043 const dy = _lastY - _startY; 1044 const shouldDismiss = dy > DISMISS_THRESHOLD || (dy > 20 && _velocity > VELOCITY_THRESHOLD); 1045 1046 el.style.willChange = ''; 1047 1048 if (shouldDismiss) { 1049 // Animate out â use remaining distance to calculate duration 1050 const remaining = el.offsetHeight - dy; 1051 const speed = Math.max(Math.abs(_velocity), 0.8); // min speed 1052 const duration = Math.min(Math.max(remaining / speed, 120), 300); 1053 el.style.transition = `transform ${duration}ms cubic-bezier(0.2, 0, 0.4, 1)`; 1054 el.style.transform = 'translateY(100%)'; 1055 setTimeout(() => { 1056 const modal = el.closest('.modal'); 1057 if (modal) { 1058 modal.classList.add('hidden'); 1059 // Some modals (calendar, email library) toggle visibility via 1060 // inline display style which would override .hidden â clear it 1061 // so the modal is actually dismissed. 1062 modal.style.display = ''; 1063 document.querySelectorAll('#settings-menu-list .list-item.active').forEach(i => i.classList.remove('active')); 1064 // Notify modules so they can sync internal open-state flags 1065 window.dispatchEvent(new CustomEvent('modal-dismissed', { detail: { id: modal.id } })); 1066 // Swiping a tool away to reveal a new/empty chat replays the welcome
1067 // "splash" reveal â the same nice effect notes gives on dismiss. 1068 // Only when the welcome screen is already the active state (new chat), 1069 // so we never cover a chat that has messages. 1070 const ws = document.getElementById('welcome-screen'); 1071 if (ws && !ws.classList.contains('hidden')) { 1072 window.chatModule?.showWelcomeScreen?.(); 1073 } 1074 } 1075 el.classList.remove('sheet-ready'); 1076 el.style.transform = ''; 1077 el.style.transition = ''; 1078 el.style.animation = ''; 1079 }, duration + 10); 1080 } else { 1081 // Snap back with spring-like easing 1082 el.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; 1083 el.style.transform = ''; 1084 setTimeout(() => { el.style.transition = ''; el.style.animation = ''; }, 260); 1085 } 1086 }, { passive: true }); 1087} 1088 1089// ---- Bring modal to front on click ---- 1090{ 1091 let topModalZ = 250; 1092 document.addEventListener('mousedown', (e) => { 1093 const modalContent = e.target.closest('.modal-content'); 1094 if (!modalContent) return; 1095 const modal = modalContent.closest('.modal'); 1096 if (!modal) return; 1097 topModalZ += 1; 1098 modal.style.zIndex = topModalZ; 1099 }); 1100 1101 // Backdrop tap to close â delegated for all modals 1102 document.addEventListener('mousedown', (e) => { 1103 if (_touchInsideModal) return; // suppress synthetic events from content scrolling 1104 if (!e.target.classList.contains('modal')) return; 1105 const modal = e.target; 1106 if (modal.classList.contains('hidden')) return; 1107 const content = modal.querySelector('.modal-content'); 1108 if (content) { 1109 content.classList.add('modal-closing'); 1110 content.addEventListener('animationend', () => { 1111 modal.classList.add('hidden'); 1112 content.classList.remove('modal-closing'); 1113 }, { once: true }); 1114 setTimeout(() => { 1115 if (!modal.classList.contains('hidden')) { 1116 modal.classList.add('hidden'); 1117 content.classList.remove('modal-closing'); 1118 } 1119 }, 300); 1120 } else { 1121 modal.classList.add('hidden'); 1122 } 1123 }); 1124} 1125 1126// ââ Mobile: keep focused inputs visible above the keyboard ââ 1127// When an input inside a modal gets focus on mobile, the OS keyboard 1128// covers the bottom half of the screen. The browser is supposed to 1129// scroll the input into view, but in bottom-sheet modals with their 1130// own scrolling container that often fails â the user types blind. 1131// Scroll the input into the middle of the still-visible viewport 1132// after the keyboard has had a moment to animate in. 1133if ('ontouchstart' in window || window.innerWidth <= 768) { 1134 let _kbScrollTimer = null; 1135 document.addEventListener('focusin', (e) => { 1136 const el = e.target; 1137 if (!el || el.nodeType !== 1) return; 1138 const tag = el.tagName; 1139 const isText = tag === 'INPUT' || tag === 'TEXTAREA' || 1140 (tag === 'DIV' && el.isContentEditable); 1141 if (!isText) return;
1142 // Inputs of type button/checkbox/radio/range/etc. don't summon a keyboard 1143 if (tag === 'INPUT') { 1144 const t = (el.type || 'text').toLowerCase(); 1145 if (['button','submit','reset','checkbox','radio','range','color','file','image'].includes(t)) return; 1146 } 1147 if (_kbScrollTimer) clearTimeout(_kbScrollTimer); 1148 // The keyboard typically takes 200â300ms to slide up; do the scroll 1149 // after that so we know the final visible viewport height. 1150 _kbScrollTimer = setTimeout(() => { 1151 _kbScrollTimer = null; 1152 // Skip the scroll if the input is already visible inside the 1153 // current viewport (with a small comfort margin). Otherwise every 1154 // re-focus â including the programmatic refocus that happens when 1155 // a typeahead input rebuilds the DOM on every keystroke â would 1156 // re-scroll the modal and yank the page up and down as the user 1157 // types. 1158 try { 1159 const r = el.getBoundingClientRect(); 1160 const vh = (window.visualViewport?.height) || window.innerHeight; 1161 const margin = 24; 1162 const fullyVisible = r.top >= margin && r.bottom <= vh - margin; 1163 if (fullyVisible) return; 1164 el.scrollIntoView({ block: 'center', behavior: 'smooth' }); 1165 } catch { 1166 try { el.scrollIntoView(); } catch {} 1167 } 1168 }, 300); 1169 }); 1170} 1171 1172// ââ Global Escape arbiter: close exactly one thing per press ââ 1173// Priority: expanded library card â open chat thinking block â topmost modal. 1174// Runs capture-phase + stopImmediatePropagation so per-modal ESC listeners 1175// never also fire (which would otherwise close several modals at once). 1176if (!window._odyEscExpandGuard) { 1177 window._odyEscExpandGuard = true; 1178 1179 // Auto-promote any modal that becomes visible to the top of the z-stack. 1180 // Every modal shares `z-index: 250` from the base `.modal` rule, so visual 1181 // stacking falls back to DOM order â which is unpredictable (cookbook is 1182 // a static HTML node, calendar gets appended once and stays, compare and 1183 // research get re-appended on each open). Result: opening compare AFTER 1184 // cookbook can render compare UNDER it. Bumping the z-index on every 1185 // open guarantees most-recently-opened wins both visually AND for ESC. 1186 let _zCounter = 1000; 1187 const _isVisible = (m) => !m.classList.contains('hidden') && getComputedStyle(m).display !== 'none'; 1188 const _promote = (m) => { 1189 if (!m?.classList?.contains('modal') || !_isVisible(m)) return; 1190 // Re-entry guard: setting style.zIndex itself fires the observer that 1191 // calls us back. Skip if this element is already pinned to the top 1192 // (matches the current counter) so we don't spin into an infinite loop. 1193 const cur = parseInt(m.style.zIndex, 10) || 0; 1194 if (cur === _zCounter) return; 1195 m.style.zIndex = String(++_zCounter); 1196 }; 1197 new MutationObserver((muts) => { 1198 for (const m of muts) { 1199 if (m.type === 'childList') m.addedNodes.forEach(n => n.nodeType === 1 && _promote(n)); 1200 else if (m.type === 'attributes' && m.target?.classList?.contains('modal')) _promote(m.target); 1201 } 1202 }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] }); 1203 document.querySelectorAll('.modal').forEach(_promote); 1204 1205 const pickTopModal = () => { 1206 const modals = [...document.querySelectorAll('.modal')].filter(_isVisible); 1207 if (!modals.length) return null; 1208 return modals.reduce((top, m) => 1209 (parseInt(getComputedStyle(m).zIndex, 10) || 0) >= (parseInt(getComputedStyle(top).zIndex, 10) || 0) 1210 ? m : top 1211 ); 1212 }; 1213 1214 document.addEventListener('keydown', (e) => { 1215 if (e.key !== 'Escape' || e.defaultPrevented) return; 1216 1217 // Find the single thing to close, in priority order. The first hit wins. 1218 // Important: if a thinking block is open we MUST handle it ourselves and 1219 // not fall through to closing a modal â even if its header is missing 1220 // (the live-stream chat rebuilds thinking DOM mid-stream so the header 1221 // can briefly be absent). Toggling the `expanded` class directly is the 1222 // fallback so ESC never bypasses the thinking block to hit a modal. 1223 if (_closeHoveredWindow()) { 1224 e.stopImmediatePropagation(); e.preventDefault(); 1225 return; 1226 } 1227 // Transient ad-hoc menus (dropdowns / context popups) live outside the 1228 // .modal system and register a dismiss callback in escMenuStack. Close the 1229 // most-recently-opened one first â so a menu opened over a modal dismisses 1230 // before the modal â and do it BEFORE the text-input guard below, since a 1231 // menu may own the focused input (e.g. a search dropdown). 1232 if (dismissTopMenu()) { 1233 e.stopImmediatePropagation(); e.preventDefault(); 1234 return; 1235 } 1236 const t = e.target; 1237 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; 1238 const expanded = document.querySelector('.doclib-card-expanded'); 1239 const think = document.querySelector('.thinking-content.expanded'); 1240 if (expanded) { 1241 e.stopImmediatePropagation(); e.preventDefault();
1242 try { expanded.click(); } catch {} 1243 return; 1244 } 1245 if (think) { 1246 e.stopImmediatePropagation(); e.preventDefault(); 1247 const thinkHeader = think.closest('.thinking-section')?.querySelector('.thinking-header[data-thinking-id]'); 1248 if (thinkHeader) { try { thinkHeader.click(); } catch {} } 1249 else { 1250 // No header found â collapse the content directly. 1251 try { think.classList.remove('expanded'); } catch {} 1252 } 1253 return; 1254 } 1255 const galleryEditor = document.getElementById('gallery-editor-container'); 1256 const galleryModal = galleryEditor?.closest('.modal'); 1257 const galleryEditing = !!( 1258 galleryEditor && 1259 galleryModal && 1260 !galleryModal.classList.contains('hidden') && 1261 getComputedStyle(galleryEditor).display !== 'none' && 1262 galleryEditor.querySelector('.gallery-editor') 1263 ); 1264 if (galleryEditing) { 1265 e.preventDefault(); 1266 e.stopImmediatePropagation(); 1267 return; 1268 } 1269 const settingsModal = document.getElementById('settings-modal'); 1270 if (settingsModal && _isVisible(settingsModal)) { 1271 const innerForm = settingsModal.querySelector('#unified-intg-form, #set-email-accounts-form'); 1272 if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) { 1273 e.preventDefault(); 1274 e.stopImmediatePropagation(); 1275 innerForm.style.display = 'none'; 1276 innerForm.innerHTML = ''; 1277 return; 1278 } 1279 } 1280 const topModal = pickTopModal(); 1281 if (!topModal) return; 1282 const closeBtn = topModal.querySelector('.close-btn, .modal-close-btn, [data-action="close"]'); 1283 e.stopImmediatePropagation(); 1284 e.preventDefault(); 1285 if (closeBtn) { try { closeBtn.click(); } catch {} } 1286 else { try { topModal.classList.add('hidden'); } catch {} } 1287 }, true); 1288}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.