1172 lines
44 KiB
JavaScript
1172 lines
44 KiB
JavaScript
// static/js/ui.js
|
||
|
||
/**
|
||
* UI utilities for toasts, modals, scrolling, and user feedback
|
||
*/
|
||
|
||
import themeModule from './theme.js';
|
||
import * as Modals from './modalManager.js';
|
||
|
||
let toastEl = null;
|
||
let autoScrollEnabled = true;
|
||
let hoveredToggleCard = null;
|
||
let hoveredToggleWindow = null;
|
||
let hoveredDockChip = null;
|
||
|
||
// Smooth scroll state
|
||
let _scrollRafId = null;
|
||
let _scrollBox = null;
|
||
|
||
function _isTextEditingTarget(target) {
|
||
const el = target && target.nodeType === 1 ? target : target?.parentElement;
|
||
return !!(el && el.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
|
||
}
|
||
|
||
function _targetEl(target) {
|
||
return target && target.nodeType === 1 ? target : target?.parentElement || null;
|
||
}
|
||
|
||
const SPACE_CARD_SELECTOR = [
|
||
'#email-lib-modal .doclib-card',
|
||
'#doclib-modal .doclib-card',
|
||
'#memory-modal .doclib-card',
|
||
'#tasks-modal .task-card',
|
||
'#tasks-modal .task-log-row',
|
||
'#research-overlay [data-job-id]',
|
||
'#cookbook-modal .doclib-card',
|
||
'.email-reader-tab-modal .doclib-card',
|
||
'.email-window-modal .doclib-card',
|
||
].join(', ');
|
||
|
||
const SPACE_BLOCKED_SELECTOR = [
|
||
'button',
|
||
'a',
|
||
'input',
|
||
'textarea',
|
||
'select',
|
||
'[contenteditable="true"]',
|
||
'[contenteditable=""]',
|
||
'.recipient-chip',
|
||
'.doclib-card-dropdown',
|
||
'.email-card-dropdown',
|
||
'.task-log-row-actions',
|
||
'.modal-header',
|
||
].join(', ');
|
||
|
||
function _visibleModalForSpace(win) {
|
||
const modal = win?.closest?.('.modal[id]');
|
||
if (!modal || modal.classList.contains('hidden') || modal.classList.contains('modal-minimized')) return null;
|
||
return modal;
|
||
}
|
||
|
||
function _isSpaceVisible(el) {
|
||
if (!el || !document.contains(el)) return false;
|
||
if (el.closest?.('.modal.hidden, .modal.modal-minimized, [hidden]')) return false;
|
||
return true;
|
||
}
|
||
|
||
function _spaceWindowId(win) {
|
||
if (!win || !document.contains(win)) return null;
|
||
const modal = _visibleModalForSpace(win);
|
||
if (modal && Modals.isRegistered(modal.id)) return modal.id;
|
||
if (win.closest?.('.doc-editor-pane') && Modals.isRegistered('doc-panel') && !Modals.isMinimized('doc-panel')) return 'doc-panel';
|
||
return null;
|
||
}
|
||
|
||
function _spaceIsBlocked(e, surface) {
|
||
const target = _targetEl(e.target);
|
||
if (!target) return false;
|
||
if (_isTextEditingTarget(target)) return !surface || surface.contains(target);
|
||
const blocked = target.closest?.(SPACE_BLOCKED_SELECTOR);
|
||
return !!(blocked && (!surface || surface.contains(blocked)));
|
||
}
|
||
|
||
function _activateSpaceCard(card) {
|
||
if (!card || !document.contains(card)) return false;
|
||
if (card.matches('#tasks-modal .task-card')) {
|
||
const titleRow = card.querySelector('.memory-item-title')?.closest('div');
|
||
if (titleRow) {
|
||
titleRow.click();
|
||
return true;
|
||
}
|
||
}
|
||
card.click();
|
||
return true;
|
||
}
|
||
|
||
function _initHoverCardSpaceToggle() {
|
||
if (document._odysseusHoverCardSpaceToggle) return;
|
||
document._odysseusHoverCardSpaceToggle = true;
|
||
document.addEventListener('pointerover', (e) => {
|
||
const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]');
|
||
if (chip) hoveredDockChip = chip;
|
||
const card = e.target?.closest?.(SPACE_CARD_SELECTOR);
|
||
if (card) hoveredToggleCard = card;
|
||
const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane');
|
||
if (win) hoveredToggleWindow = win;
|
||
}, true);
|
||
document.addEventListener('pointerout', (e) => {
|
||
const next = e.relatedTarget;
|
||
if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null;
|
||
if (hoveredToggleCard && (!next || !hoveredToggleCard.contains(next))) hoveredToggleCard = null;
|
||
if (hoveredToggleWindow && (!next || !hoveredToggleWindow.contains(next))) hoveredToggleWindow = null;
|
||
}, true);
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.code !== 'Space' || e.repeat) return;
|
||
if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {
|
||
if (_spaceIsBlocked(e, hoveredToggleCard)) return;
|
||
e.preventDefault();
|
||
_activateSpaceCard(hoveredToggleCard);
|
||
return;
|
||
}
|
||
if (hoveredDockChip && document.contains(hoveredDockChip)) {
|
||
if (_spaceIsBlocked(e, hoveredDockChip)) return;
|
||
const id = hoveredDockChip.dataset.modalId;
|
||
if (id && Modals.isRegistered(id)) {
|
||
e.preventDefault();
|
||
Modals.restore(id);
|
||
}
|
||
return;
|
||
}
|
||
const id = _spaceWindowId(hoveredToggleWindow);
|
||
if (!id) return;
|
||
if (_spaceIsBlocked(e, hoveredToggleWindow)) return;
|
||
e.preventDefault();
|
||
Modals.minimize(id);
|
||
}, true);
|
||
}
|
||
|
||
_initHoverCardSpaceToggle();
|
||
|
||
/**
|
||
* Copy text to clipboard
|
||
*/
|
||
export async function copyToClipboard(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
showToast('Copied');
|
||
}
|
||
catch {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
showToast('Copied');
|
||
}
|
||
}
|
||
|
||
// Wire swipe-to-dismiss on the shared toast element. Runs once, the first
|
||
// time a toast is shown. Tracks horizontal touch drag; if the user drags
|
||
// more than DISMISS_PX, the toast slides off in the drag direction and
|
||
// hides early. Anything less snaps back. Desktop unaffected (touch
|
||
// listeners only fire from a touchscreen — mouse is handled by the
|
||
// existing × button and auto-hide timer).
|
||
function _wireToastSwipe(el) {
|
||
if (!el || el._swipeWired) return;
|
||
el._swipeWired = true;
|
||
const DISMISS_PX = 70;
|
||
let startX = 0, currentX = 0, swiping = false;
|
||
el.addEventListener('touchstart', (e) => {
|
||
if (!el.classList.contains('show')) return;
|
||
const t = e.touches[0];
|
||
if (!t) return;
|
||
startX = t.clientX;
|
||
currentX = t.clientX;
|
||
swiping = true;
|
||
// Kill the slide-in transition mid-flight so the touch tracks the
|
||
// finger 1:1 instead of fighting a still-running animation.
|
||
el.style.transition = 'none';
|
||
}, { passive: true });
|
||
el.addEventListener('touchmove', (e) => {
|
||
if (!swiping) return;
|
||
const t = e.touches[0];
|
||
if (!t) return;
|
||
currentX = t.clientX;
|
||
const dx = currentX - startX;
|
||
el.style.transform = `translateX(${dx}px)`;
|
||
// Fade as the toast leaves the rest position — visual cue for
|
||
// approaching the dismiss threshold.
|
||
el.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 200));
|
||
}, { passive: true });
|
||
const endSwipe = () => {
|
||
if (!swiping) return;
|
||
swiping = false;
|
||
const dx = currentX - startX;
|
||
// Restore the transition so the next mutation animates.
|
||
el.style.transition = '';
|
||
if (Math.abs(dx) > DISMISS_PX) {
|
||
// Fling off in the drag direction, then hide.
|
||
el.style.transform = `translateX(${dx > 0 ? '120%' : '-120%'})`;
|
||
el.style.opacity = '0';
|
||
clearTimeout(el._hideTimer);
|
||
setTimeout(() => {
|
||
el.classList.remove('show');
|
||
el.classList.add('exiting');
|
||
el.style.transform = '';
|
||
el.style.opacity = '';
|
||
}, 180);
|
||
} else {
|
||
// Snap back to rest.
|
||
el.style.transform = '';
|
||
el.style.opacity = '';
|
||
}
|
||
};
|
||
el.addEventListener('touchend', endSwipe);
|
||
el.addEventListener('touchcancel', endSwipe);
|
||
}
|
||
|
||
/**
|
||
* Show success toast message
|
||
*/
|
||
export function showToast(msg, durationOrOpts) {
|
||
if (!toastEl) {
|
||
toastEl = document.getElementById('toast');
|
||
}
|
||
_wireToastSwipe(toastEl);
|
||
toastEl.textContent = '';
|
||
toastEl.classList.remove('error');
|
||
|
||
let duration = 1200, actionLabel = null, onAction = null, actionHint = null, actionIcon = null, leadingIcon = null;
|
||
if (typeof durationOrOpts === 'object' && durationOrOpts) {
|
||
duration = durationOrOpts.duration || 5000;
|
||
actionLabel = durationOrOpts.action;
|
||
onAction = durationOrOpts.onAction;
|
||
actionHint = durationOrOpts.actionHint || null;
|
||
actionIcon = durationOrOpts.actionIcon || null;
|
||
leadingIcon = durationOrOpts.leadingIcon || null;
|
||
} else if (typeof durationOrOpts === 'number') {
|
||
duration = durationOrOpts;
|
||
}
|
||
|
||
const textSpan = document.createElement('span');
|
||
if (leadingIcon === 'check') {
|
||
const icon = document.createElement('span');
|
||
icon.className = 'toast-checkmark';
|
||
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>';
|
||
toastEl.appendChild(icon);
|
||
}
|
||
textSpan.textContent = msg;
|
||
toastEl.appendChild(textSpan);
|
||
|
||
if (actionLabel && onAction) {
|
||
// Wrap the action in a small column so we can stack a Ctrl-Z-style hint
|
||
// directly under the button.
|
||
const stack = document.createElement('span');
|
||
stack.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:1px;margin-left:10px;line-height:1;';
|
||
|
||
const btn = document.createElement('button');
|
||
// If the caller supplied an SVG icon, prepend it. We trust the icon string
|
||
// (only set internally) — never accept caller-controlled HTML otherwise.
|
||
if (actionIcon) {
|
||
btn.innerHTML = `<span style="display:inline-flex;align-items:center;gap:5px;">${actionIcon}<span></span></span>`;
|
||
btn.querySelector('span span').textContent = actionLabel;
|
||
} else {
|
||
btn.textContent = actionLabel;
|
||
}
|
||
// The toast itself is `pointer-events: none` so it doesn't block clicks
|
||
// beneath it. With an action button we need to flip both the toast AND
|
||
// the button so the user can actually click Undo. The flag is reset on
|
||
// the next plain showToast / showError call (those overwrite textContent
|
||
// which strips the button + we clear inline style at the top below).
|
||
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;';
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
toastEl.classList.remove('show');
|
||
onAction();
|
||
});
|
||
stack.appendChild(btn);
|
||
|
||
// Keyboard-shortcut hints (Ctrl+Z / ⌘Z) are meaningless on touch devices —
|
||
// skip them on mobile so the toast just shows the Undo button.
|
||
if (actionHint && window.innerWidth > 768) {
|
||
const hint = document.createElement('span');
|
||
hint.textContent = actionHint;
|
||
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;';
|
||
stack.appendChild(hint);
|
||
}
|
||
|
||
toastEl.appendChild(stack);
|
||
|
||
// Small × to dismiss the toast without taking the action. Useful when
|
||
// the user already acted (or just doesn't want the banner sitting there).
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||
closeBtn.title = 'Dismiss';
|
||
closeBtn.textContent = '×';
|
||
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;';
|
||
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.opacity = '1'; });
|
||
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.opacity = '0.55'; });
|
||
closeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
clearTimeout(toastEl._hideTimer);
|
||
toastEl.classList.add('exiting');
|
||
toastEl.classList.remove('show');
|
||
});
|
||
toastEl.appendChild(closeBtn);
|
||
|
||
toastEl.style.pointerEvents = 'auto';
|
||
} else {
|
||
// No action — restore the default non-blocking behavior.
|
||
toastEl.style.pointerEvents = '';
|
||
}
|
||
|
||
// Pin to top-right via CSS — clear any legacy inline overrides so the
|
||
// slide-in-from-right / slide-out-to-left transition can run cleanly.
|
||
toastEl.style.left = '';
|
||
toastEl.style.transform = '';
|
||
toastEl.classList.remove('exiting');
|
||
toastEl.classList.add('show');
|
||
clearTimeout(toastEl._hideTimer);
|
||
toastEl._hideTimer = setTimeout(() => {
|
||
// Add `exiting` so the CSS rule slides it off to the LEFT instead of
|
||
// back to the right (where it came from). We piggyback on the same
|
||
// .toast base; .exiting overrides the resting transform.
|
||
toastEl.classList.add('exiting');
|
||
toastEl.classList.remove('show');
|
||
// Reset pointer-events so an action-toast (which sets it to 'auto'
|
||
// for its clickable button) doesn't leave the toast intercepting
|
||
// clicks after it's slid away. Was previously only cleared on the
|
||
// NEXT plain toast, so a lingering action-toast could appear to
|
||
// "lock" interaction near the top-right.
|
||
toastEl.style.pointerEvents = '';
|
||
}, duration);
|
||
}
|
||
|
||
/**
|
||
* Show error toast message
|
||
*/
|
||
export function showError(msg) {
|
||
if (!toastEl) {
|
||
toastEl = document.getElementById('toast');
|
||
}
|
||
_wireToastSwipe(toastEl);
|
||
toastEl.textContent = msg;
|
||
toastEl.classList.add('error');
|
||
toastEl.style.left = '';
|
||
toastEl.style.transform = '';
|
||
toastEl.classList.remove('exiting');
|
||
toastEl.classList.add('show');
|
||
clearTimeout(toastEl._hideTimer);
|
||
toastEl._hideTimer = setTimeout(() => {
|
||
toastEl.classList.add('exiting');
|
||
toastEl.classList.remove('show');
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* Smooth-scroll chat history to bottom using rAF lerp.
|
||
* Throttled during streaming so it doesn't fight user scrolling.
|
||
*/
|
||
let _scrollThrottleTimer = null;
|
||
export function scrollHistory() {
|
||
if (!autoScrollEnabled) return;
|
||
if (!_scrollBox) {
|
||
_scrollBox = document.getElementById('chat-history');
|
||
}
|
||
// Throttle: only start a new scroll animation every 500ms
|
||
if (_scrollThrottleTimer) return;
|
||
_scrollThrottleTimer = setTimeout(() => { _scrollThrottleTimer = null; }, 500);
|
||
if (!_scrollRafId) {
|
||
_scrollRafId = requestAnimationFrame(_smoothScrollStep);
|
||
}
|
||
}
|
||
|
||
function _smoothScrollStep() {
|
||
const box = _scrollBox;
|
||
if (!box || !autoScrollEnabled) {
|
||
_scrollRafId = null;
|
||
return;
|
||
}
|
||
const target = box.scrollHeight - box.clientHeight;
|
||
const current = box.scrollTop;
|
||
const diff = target - current;
|
||
|
||
// If user scrolled up significantly, don't force them down
|
||
if (diff > 300) {
|
||
_scrollRafId = null;
|
||
return;
|
||
}
|
||
|
||
if (diff <= 1) {
|
||
box.scrollTop = target;
|
||
_scrollRafId = null;
|
||
return;
|
||
}
|
||
|
||
// Lerp: gentle catch-up
|
||
const factor = window.innerWidth <= 768 ? 0.4 : 0.2;
|
||
box.scrollTop = current + diff * factor;
|
||
_scrollRafId = requestAnimationFrame(_smoothScrollStep);
|
||
}
|
||
|
||
/**
|
||
* Instant scroll to bottom — use for non-streaming contexts
|
||
* like loading history or switching sessions.
|
||
*/
|
||
export function scrollHistoryInstant() {
|
||
if (!_scrollBox) {
|
||
_scrollBox = document.getElementById('chat-history');
|
||
}
|
||
if (_scrollBox) {
|
||
_scrollBox.scrollTop = _scrollBox.scrollHeight;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enable/disable auto-scroll
|
||
*/
|
||
export function setAutoScroll(enabled) {
|
||
autoScrollEnabled = enabled;
|
||
}
|
||
|
||
/**
|
||
* Get auto-scroll state
|
||
*/
|
||
export function getAutoScroll() {
|
||
return autoScrollEnabled;
|
||
}
|
||
|
||
/**
|
||
* Auto-resize textarea based on content
|
||
*/
|
||
export function autoResize(textarea) {
|
||
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
|
||
const isMobile = window.innerWidth <= 768;
|
||
const maxHeight = isMobile ? 150 : lineHeight * 8;
|
||
|
||
// Use a hidden clone to measure without disrupting the real textarea
|
||
let clone = textarea._resizeClone;
|
||
if (!clone) {
|
||
clone = textarea.cloneNode(false);
|
||
clone.style.cssText = getComputedStyle(textarea).cssText;
|
||
clone.style.position = 'absolute';
|
||
clone.style.visibility = 'hidden';
|
||
clone.style.height = '0';
|
||
clone.style.transition = 'none';
|
||
clone.style.overflow = 'hidden';
|
||
clone.style.pointerEvents = 'none';
|
||
clone.style.zIndex = '-1';
|
||
textarea.parentNode.appendChild(clone);
|
||
textarea._resizeClone = clone;
|
||
}
|
||
clone.style.width = textarea.offsetWidth + 'px';
|
||
clone.value = textarea.value;
|
||
clone.style.height = '0';
|
||
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight);
|
||
textarea.style.height = newHeight + 'px';
|
||
textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden';
|
||
}
|
||
|
||
/**
|
||
* Debounce function for performance
|
||
*/
|
||
export function debounce(func, wait) {
|
||
let timeout;
|
||
return function(...args) {
|
||
const later = () => {
|
||
timeout = null;
|
||
func.apply(this, args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get element by ID (utility helper)
|
||
*/
|
||
export function el(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
/**
|
||
* Styled confirm dialog — replaces native browser confirm().
|
||
* Returns a Promise<boolean>.
|
||
*/
|
||
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
|
||
return new Promise(resolve => {
|
||
// Reuse or create the modal
|
||
let overlay = document.getElementById('styled-confirm-overlay');
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'styled-confirm-overlay';
|
||
overlay.className = 'modal';
|
||
overlay.innerHTML =
|
||
'<div class="modal-content styled-confirm-box">' +
|
||
'<div class="modal-header"><h4>Confirm</h4></div>' +
|
||
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
|
||
'<div class="modal-footer">' +
|
||
'<button id="styled-confirm-cancel"></button>' +
|
||
'<button id="styled-confirm-ok"></button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
const msgEl = document.getElementById('styled-confirm-msg');
|
||
const okBtn = document.getElementById('styled-confirm-ok');
|
||
const cancelBtn = document.getElementById('styled-confirm-cancel');
|
||
|
||
msgEl.textContent = message;
|
||
okBtn.textContent = confirmText;
|
||
cancelBtn.textContent = cancelText;
|
||
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
|
||
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
|
||
|
||
overlay.classList.remove('hidden');
|
||
overlay.style.display = '';
|
||
|
||
function cleanup(result) {
|
||
overlay.classList.add('hidden');
|
||
overlay.style.display = 'none';
|
||
okBtn.removeEventListener('click', onOk);
|
||
cancelBtn.removeEventListener('click', onCancel);
|
||
overlay.removeEventListener('click', onBackdrop);
|
||
document.removeEventListener('keydown', onKey);
|
||
resolve(result);
|
||
}
|
||
function onOk() { cleanup(true); }
|
||
function onCancel() { cleanup(false); }
|
||
function onBackdrop(e) { if (e.target === overlay) cleanup(false); }
|
||
function onKey(e) {
|
||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
const active = document.activeElement;
|
||
if (active === okBtn) cancelBtn.focus();
|
||
else okBtn.focus();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
cleanup(false);
|
||
}
|
||
}
|
||
|
||
okBtn.addEventListener('click', onOk);
|
||
cancelBtn.addEventListener('click', onCancel);
|
||
overlay.addEventListener('click', onBackdrop);
|
||
document.addEventListener('keydown', onKey);
|
||
okBtn.focus();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Styled text-input prompt — drop-in replacement for window.prompt().
|
||
* Resolves to the trimmed string the user typed, or null on Cancel / Escape / backdrop.
|
||
*/
|
||
export function styledPrompt(message, {
|
||
title = 'Name',
|
||
defaultValue = '',
|
||
placeholder = '',
|
||
confirmText = 'Save',
|
||
cancelText = 'Cancel',
|
||
maxLength = 80,
|
||
} = {}) {
|
||
return new Promise(resolve => {
|
||
let overlay = document.getElementById('styled-prompt-overlay');
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'styled-prompt-overlay';
|
||
overlay.className = 'modal';
|
||
overlay.innerHTML =
|
||
'<div class="modal-content styled-confirm-box styled-prompt-box">' +
|
||
'<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' +
|
||
'<div class="modal-body">' +
|
||
'<p id="styled-prompt-msg"></p>' +
|
||
'<input type="text" id="styled-prompt-input" class="styled-prompt-input" />' +
|
||
'</div>' +
|
||
'<div class="modal-footer">' +
|
||
'<button id="styled-prompt-cancel" class="confirm-btn confirm-btn-secondary"></button>' +
|
||
'<button id="styled-prompt-ok" class="confirm-btn confirm-btn-primary"></button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
const titleEl = document.getElementById('styled-prompt-title');
|
||
const msgEl = document.getElementById('styled-prompt-msg');
|
||
const input = document.getElementById('styled-prompt-input');
|
||
const okBtn = document.getElementById('styled-prompt-ok');
|
||
const cancelBtn = document.getElementById('styled-prompt-cancel');
|
||
|
||
titleEl.textContent = title;
|
||
msgEl.textContent = message || '';
|
||
msgEl.style.display = message ? '' : 'none';
|
||
input.value = defaultValue || '';
|
||
input.placeholder = placeholder || '';
|
||
input.maxLength = maxLength;
|
||
okBtn.textContent = confirmText;
|
||
cancelBtn.textContent = cancelText;
|
||
|
||
overlay.classList.remove('hidden');
|
||
overlay.style.display = '';
|
||
|
||
function cleanup(result) {
|
||
overlay.classList.add('hidden');
|
||
overlay.style.display = 'none';
|
||
okBtn.removeEventListener('click', onOk);
|
||
cancelBtn.removeEventListener('click', onCancel);
|
||
overlay.removeEventListener('click', onBackdrop);
|
||
document.removeEventListener('keydown', onKey);
|
||
input.removeEventListener('keydown', onInputKey);
|
||
resolve(result);
|
||
}
|
||
function onOk() { cleanup((input.value || '').trim()); }
|
||
function onCancel() { cleanup(null); }
|
||
function onBackdrop(e) { if (e.target === overlay) cleanup(null); }
|
||
function onKey(e) {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
cleanup(null);
|
||
}
|
||
}
|
||
function onInputKey(e) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
onOk();
|
||
}
|
||
}
|
||
|
||
okBtn.addEventListener('click', onOk);
|
||
cancelBtn.addEventListener('click', onCancel);
|
||
overlay.addEventListener('click', onBackdrop);
|
||
document.addEventListener('keydown', onKey);
|
||
input.addEventListener('keydown', onInputKey);
|
||
|
||
requestAnimationFrame(() => {
|
||
input.focus();
|
||
input.select();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Lookup table for esc(); hoisted out of the replace callback so it is
|
||
// allocated once rather than per matched character.
|
||
const _ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||
/**
|
||
* HTML-escape a string to prevent XSS.
|
||
* Canonical implementation — other modules should use uiModule.esc() instead of local copies.
|
||
*/
|
||
export function esc(s) {
|
||
return (s || '').replace(/[&<>"']/g, (m) => _ESC_MAP[m]);
|
||
}
|
||
|
||
// ── Mobile: suppress synthetic click/mousedown on backdrop ──
|
||
// When a touch starts inside .modal-content, set a flag so that
|
||
// synthetic mouse events on the backdrop are ignored.
|
||
let _touchInsideModal = false;
|
||
if ('ontouchstart' in window) {
|
||
document.addEventListener('touchstart', (e) => {
|
||
if (e.target.closest('.modal-content')) {
|
||
_touchInsideModal = true;
|
||
}
|
||
}, { passive: true });
|
||
document.addEventListener('touchend', () => {
|
||
// Clear after a short delay — synthetic click fires ~300ms after touchend
|
||
setTimeout(() => { _touchInsideModal = false; }, 400);
|
||
}, { passive: true });
|
||
}
|
||
|
||
/**
|
||
* Check if a backdrop dismiss should be suppressed on mobile.
|
||
* Other modules can call this to guard their own backdrop handlers.
|
||
*/
|
||
export function isTouchInsideModal() {
|
||
return _touchInsideModal;
|
||
}
|
||
|
||
// Close floating dropdowns/popups on scroll to prevent them drifting
|
||
function _initScrollDismiss() {
|
||
const chatHistory = document.getElementById('chat-history');
|
||
if (chatHistory) {
|
||
chatHistory.addEventListener('scroll', () => {
|
||
chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show'));
|
||
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
|
||
}, { passive: true });
|
||
} else {
|
||
// Retry once if element doesn't exist yet
|
||
setTimeout(_initScrollDismiss, 500);
|
||
}
|
||
}
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', _initScrollDismiss);
|
||
} else {
|
||
_initScrollDismiss();
|
||
}
|
||
|
||
/**
|
||
* Returns the SVG string for an empty-state icon. `kind` is one of
|
||
* 'smiley' | 'sad' | 'neutral'. The returned <svg> has NO inline style —
|
||
* callers wrap with `<span style="vertical-align:-3px;margin-left:6px;">…</span>`
|
||
* (or similar) to keep the per-site visual nudge they need.
|
||
*/
|
||
export function emptyStateIcon(kind) {
|
||
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">';
|
||
const SVG_CLOSE = '</svg>';
|
||
let inner;
|
||
switch (kind) {
|
||
case 'sad':
|
||
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"/>';
|
||
break;
|
||
case 'neutral':
|
||
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"/>';
|
||
break;
|
||
case 'smiley':
|
||
default:
|
||
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"/>';
|
||
break;
|
||
}
|
||
return SVG_OPEN + inner + SVG_CLOSE;
|
||
}
|
||
|
||
const uiModule = {
|
||
copyToClipboard,
|
||
showToast,
|
||
showError,
|
||
styledConfirm,
|
||
styledPrompt,
|
||
scrollHistory,
|
||
scrollHistoryInstant,
|
||
setAutoScroll,
|
||
getAutoScroll,
|
||
autoResize,
|
||
debounce,
|
||
el,
|
||
esc,
|
||
isTouchInsideModal,
|
||
emptyStateIcon
|
||
};
|
||
|
||
export default uiModule;
|
||
|
||
// Expose the styled confirm globally so any module can replace the native
|
||
// browser confirm() with the themed dialog — even files that don't import
|
||
// uiModule. Usage: `if (!await window.styledConfirm(msg, { danger:true })) return;`
|
||
if (typeof window !== 'undefined') {
|
||
window.styledConfirm = styledConfirm;
|
||
}
|
||
|
||
// ── Mobile: clear enter animation so inline transform works for dragging ──
|
||
// The CSS `animation: sheet-enter ... forwards` holds the final transform,
|
||
// blocking any inline style changes. We clear it once the animation completes.
|
||
if ('ontouchstart' in window || window.innerWidth <= 768) {
|
||
document.addEventListener('animationend', (e) => {
|
||
if (e.animationName === 'sheet-enter' &&
|
||
(e.target.classList.contains('modal-content') || e.target.id === 'theme-popup')) {
|
||
e.target.classList.add('sheet-ready');
|
||
}
|
||
});
|
||
// When a modal is re-shown, remove sheet-ready so the enter animation plays again
|
||
new MutationObserver((mutations) => {
|
||
for (const m of mutations) {
|
||
if (m.type === 'attributes' && m.attributeName === 'class') {
|
||
const modal = m.target;
|
||
if (modal.classList.contains('modal') && !modal.classList.contains('hidden')) {
|
||
const content = modal.querySelector('.modal-content') || modal.querySelector('#theme-popup');
|
||
if (content) {
|
||
content.classList.remove('sheet-ready', 'modal-closing');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] });
|
||
}
|
||
|
||
// ── Mobile swipe-down-to-dismiss for bottom sheet modals ──
|
||
// Finger-following drag with velocity-based dismiss.
|
||
// Works from grab handle, header, OR anywhere on the sheet when content is scrolled to top.
|
||
if ('ontouchstart' in window) {
|
||
const DISMISS_THRESHOLD = 50; // px — dismiss if dragged past this
|
||
const VELOCITY_THRESHOLD = 0.3; // px/ms — fast flick dismisses even below threshold
|
||
const RUBBER_RESISTANCE = 0.35; // drag resistance when pulling up past origin
|
||
|
||
let _swipeTarget = null;
|
||
let _startY = 0, _startX = 0;
|
||
let _lastY = 0, _lastT = 0;
|
||
let _velocity = 0;
|
||
let _dragging = false; // true once we've committed to a vertical drag
|
||
let _cancelled = false; // true if horizontal movement detected
|
||
|
||
// Close any floating dropdowns/menus that hang off body via position:fixed.
|
||
// Called when a swipe-dismiss gesture starts so the menu doesn't orphan over
|
||
// the page after the sheet slides away.
|
||
function _closeFloatingDropdownsForSwipe() {
|
||
document.querySelectorAll(
|
||
'.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
|
||
).forEach(d => {
|
||
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active');
|
||
d.remove();
|
||
});
|
||
}
|
||
|
||
document.addEventListener('touchstart', (e) => {
|
||
// Match .modal-content or #theme-popup (which acts as modal-content but uses its own ID)
|
||
const content = e.target.closest('.modal-content') || e.target.closest('#theme-popup');
|
||
if (!content) return;
|
||
|
||
// The image editor owns all touches inside its container so the user
|
||
// can paint / move layers / draw selections without the modal trying
|
||
// to interpret it as a swipe-to-dismiss gesture. Skip the swipe init
|
||
// entirely when the touch starts inside the editor area.
|
||
if (e.target.closest('.gallery-editor, .gallery-editor-container')) return;
|
||
// Internal vertical drag handles (e.g. the calendar's cal-splitter that
|
||
// resizes the day-detail pane) consume vertical touches themselves. If
|
||
// we don't bail here, the swipe-dismiss path also tracks the touch and
|
||
// slides the whole modal down as the user drags the handle. The
|
||
// [data-no-swipe-dismiss] hook lets other components opt out the same
|
||
// way without having to hard-code their selector here.
|
||
if (e.target.closest('.cal-splitter, [data-no-swipe-dismiss]')) return;
|
||
|
||
// Only allow swipe-dismiss from header or grab handle (top 48px)
|
||
const isHeader = !!e.target.closest('.modal-header');
|
||
const isButton = !!e.target.closest('button, input, select, label');
|
||
if (isHeader && isButton) return; // let button clicks through
|
||
const touch = e.touches[0];
|
||
const contentRect = content.getBoundingClientRect();
|
||
const isGrabZone = (touch.clientY - contentRect.top) < 48;
|
||
// Also allow swipe-dismiss from anywhere on the sheet when it's already
|
||
// scrolled to the top — feels natural and matches iOS bottom-sheet UX.
|
||
const isAtScrollTop = content.scrollTop <= 0;
|
||
|
||
if (!isHeader && !isGrabZone && !isAtScrollTop) return; // body touches → let native scroll handle it
|
||
|
||
_swipeTarget = content;
|
||
// Ensure CSS animation is cleared so inline transform works
|
||
content.classList.add('sheet-ready');
|
||
content.style.animation = 'none';
|
||
_startY = touch.clientY;
|
||
_startX = touch.clientX;
|
||
_lastY = _startY;
|
||
_lastT = e.timeStamp;
|
||
_velocity = 0;
|
||
_dragging = false;
|
||
_cancelled = false;
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('touchmove', (e) => {
|
||
if (!_swipeTarget || _cancelled) return;
|
||
const touch = e.touches[0];
|
||
const dx = Math.abs(touch.clientX - _startX);
|
||
const dy = touch.clientY - _startY;
|
||
|
||
// First few pixels: decide if this is horizontal scroll or content scroll
|
||
if (!_dragging) {
|
||
if (dx > 40 && dx > Math.abs(dy) * 2) {
|
||
_swipeTarget.style.transform = '';
|
||
_swipeTarget = null;
|
||
_cancelled = true;
|
||
return;
|
||
}
|
||
if (Math.abs(dy) > 8) {
|
||
// Find the nearest scrollable ancestor of the touch point
|
||
let scrollEl = e.target;
|
||
while (scrollEl && scrollEl !== _swipeTarget) {
|
||
if (scrollEl.scrollHeight > scrollEl.clientHeight + 1) {
|
||
const ov = getComputedStyle(scrollEl).overflowY;
|
||
if (ov === 'auto' || ov === 'scroll') break;
|
||
}
|
||
scrollEl = scrollEl.parentElement;
|
||
}
|
||
const hasScroller = scrollEl && scrollEl !== _swipeTarget;
|
||
// If touch is inside a scrollable child, let native scroll handle it
|
||
if (hasScroller) {
|
||
_swipeTarget.style.transform = '';
|
||
_swipeTarget = null;
|
||
_cancelled = true;
|
||
return;
|
||
}
|
||
// If swiping up and modal-content itself is scrollable, let native handle it
|
||
if (dy < 0 && _swipeTarget.scrollHeight > _swipeTarget.clientHeight + 1) {
|
||
_swipeTarget.style.transform = '';
|
||
_swipeTarget = null;
|
||
_cancelled = true;
|
||
return;
|
||
}
|
||
// If swiping down but content isn't at the top, let native scroll
|
||
if (dy > 0 && _swipeTarget.scrollTop > 0) {
|
||
_swipeTarget.style.transform = '';
|
||
_swipeTarget = null;
|
||
_cancelled = true;
|
||
return;
|
||
}
|
||
_dragging = true;
|
||
_swipeTarget.style.transition = 'none';
|
||
_swipeTarget.style.willChange = 'transform';
|
||
// A swipe is starting — close any floating menus/dropdowns so they
|
||
// don't orphan over the page once the sheet slides away. Covers the
|
||
// email reader More menu, cookbook serve kebab + saved-configs, and
|
||
// anything else hanging off body via _anchor.
|
||
_closeFloatingDropdownsForSwipe();
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Track velocity (exponential moving average)
|
||
const dt = e.timeStamp - _lastT;
|
||
if (dt > 0) {
|
||
const instantV = (touch.clientY - _lastY) / dt;
|
||
_velocity = _velocity * 0.6 + instantV * 0.4;
|
||
}
|
||
_lastY = touch.clientY;
|
||
_lastT = e.timeStamp;
|
||
|
||
e.preventDefault();
|
||
if (dy > 0) {
|
||
_swipeTarget.style.transform = `translateY(${dy}px)`;
|
||
} else {
|
||
const rubberDy = dy * RUBBER_RESISTANCE;
|
||
_swipeTarget.style.transform = `translateY(${rubberDy}px)`;
|
||
}
|
||
}, { passive: false });
|
||
|
||
document.addEventListener('touchend', (e) => {
|
||
if (!_swipeTarget || !_dragging) {
|
||
_swipeTarget = null;
|
||
return;
|
||
}
|
||
const el = _swipeTarget;
|
||
_swipeTarget = null;
|
||
|
||
const dy = _lastY - _startY;
|
||
const shouldDismiss = dy > DISMISS_THRESHOLD || (dy > 20 && _velocity > VELOCITY_THRESHOLD);
|
||
|
||
el.style.willChange = '';
|
||
|
||
if (shouldDismiss) {
|
||
// Animate out — use remaining distance to calculate duration
|
||
const remaining = el.offsetHeight - dy;
|
||
const speed = Math.max(Math.abs(_velocity), 0.8); // min speed
|
||
const duration = Math.min(Math.max(remaining / speed, 120), 300);
|
||
el.style.transition = `transform ${duration}ms cubic-bezier(0.2, 0, 0.4, 1)`;
|
||
el.style.transform = 'translateY(100%)';
|
||
setTimeout(() => {
|
||
const modal = el.closest('.modal');
|
||
if (modal) {
|
||
modal.classList.add('hidden');
|
||
// Some modals (calendar, email library) toggle visibility via
|
||
// inline display style which would override .hidden — clear it
|
||
// so the modal is actually dismissed.
|
||
modal.style.display = '';
|
||
document.querySelectorAll('#settings-menu-list .list-item.active').forEach(i => i.classList.remove('active'));
|
||
// Notify modules so they can sync internal open-state flags
|
||
window.dispatchEvent(new CustomEvent('modal-dismissed', { detail: { id: modal.id } }));
|
||
// Swiping a tool away to reveal a new/empty chat replays the welcome
|
||
// "splash" reveal — the same nice effect notes gives on dismiss.
|
||
// Only when the welcome screen is already the active state (new chat),
|
||
// so we never cover a chat that has messages.
|
||
const ws = document.getElementById('welcome-screen');
|
||
if (ws && !ws.classList.contains('hidden')) {
|
||
window.chatModule?.showWelcomeScreen?.();
|
||
}
|
||
}
|
||
el.classList.remove('sheet-ready');
|
||
el.style.transform = '';
|
||
el.style.transition = '';
|
||
el.style.animation = '';
|
||
}, duration + 10);
|
||
} else {
|
||
// Snap back with spring-like easing
|
||
el.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)';
|
||
el.style.transform = '';
|
||
setTimeout(() => { el.style.transition = ''; el.style.animation = ''; }, 260);
|
||
}
|
||
}, { passive: true });
|
||
}
|
||
|
||
// ---- Bring modal to front on click ----
|
||
{
|
||
let topModalZ = 250;
|
||
document.addEventListener('mousedown', (e) => {
|
||
const modalContent = e.target.closest('.modal-content');
|
||
if (!modalContent) return;
|
||
const modal = modalContent.closest('.modal');
|
||
if (!modal) return;
|
||
topModalZ += 1;
|
||
modal.style.zIndex = topModalZ;
|
||
});
|
||
|
||
// Backdrop tap to close — delegated for all modals
|
||
document.addEventListener('mousedown', (e) => {
|
||
if (_touchInsideModal) return; // suppress synthetic events from content scrolling
|
||
if (!e.target.classList.contains('modal')) return;
|
||
const modal = e.target;
|
||
if (modal.classList.contains('hidden')) return;
|
||
const content = modal.querySelector('.modal-content');
|
||
if (content) {
|
||
content.classList.add('modal-closing');
|
||
content.addEventListener('animationend', () => {
|
||
modal.classList.add('hidden');
|
||
content.classList.remove('modal-closing');
|
||
}, { once: true });
|
||
setTimeout(() => {
|
||
if (!modal.classList.contains('hidden')) {
|
||
modal.classList.add('hidden');
|
||
content.classList.remove('modal-closing');
|
||
}
|
||
}, 300);
|
||
} else {
|
||
modal.classList.add('hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Mobile: keep focused inputs visible above the keyboard ──
|
||
// When an input inside a modal gets focus on mobile, the OS keyboard
|
||
// covers the bottom half of the screen. The browser is supposed to
|
||
// scroll the input into view, but in bottom-sheet modals with their
|
||
// own scrolling container that often fails — the user types blind.
|
||
// Scroll the input into the middle of the still-visible viewport
|
||
// after the keyboard has had a moment to animate in.
|
||
if ('ontouchstart' in window || window.innerWidth <= 768) {
|
||
let _kbScrollTimer = null;
|
||
document.addEventListener('focusin', (e) => {
|
||
const el = e.target;
|
||
if (!el || el.nodeType !== 1) return;
|
||
const tag = el.tagName;
|
||
const isText = tag === 'INPUT' || tag === 'TEXTAREA' ||
|
||
(tag === 'DIV' && el.isContentEditable);
|
||
if (!isText) return;
|
||
// Inputs of type button/checkbox/radio/range/etc. don't summon a keyboard
|
||
if (tag === 'INPUT') {
|
||
const t = (el.type || 'text').toLowerCase();
|
||
if (['button','submit','reset','checkbox','radio','range','color','file','image'].includes(t)) return;
|
||
}
|
||
if (_kbScrollTimer) clearTimeout(_kbScrollTimer);
|
||
// The keyboard typically takes 200–300ms to slide up; do the scroll
|
||
// after that so we know the final visible viewport height.
|
||
_kbScrollTimer = setTimeout(() => {
|
||
_kbScrollTimer = null;
|
||
// Skip the scroll if the input is already visible inside the
|
||
// current viewport (with a small comfort margin). Otherwise every
|
||
// re-focus — including the programmatic refocus that happens when
|
||
// a typeahead input rebuilds the DOM on every keystroke — would
|
||
// re-scroll the modal and yank the page up and down as the user
|
||
// types.
|
||
try {
|
||
const r = el.getBoundingClientRect();
|
||
const vh = (window.visualViewport?.height) || window.innerHeight;
|
||
const margin = 24;
|
||
const fullyVisible = r.top >= margin && r.bottom <= vh - margin;
|
||
if (fullyVisible) return;
|
||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
} catch {
|
||
try { el.scrollIntoView(); } catch {}
|
||
}
|
||
}, 300);
|
||
});
|
||
}
|
||
|
||
// ── Global Escape arbiter: close exactly one thing per press ──
|
||
// Priority: expanded library card → open chat thinking block → topmost modal.
|
||
// Runs capture-phase + stopImmediatePropagation so per-modal ESC listeners
|
||
// never also fire (which would otherwise close several modals at once).
|
||
if (!window._odyEscExpandGuard) {
|
||
window._odyEscExpandGuard = true;
|
||
|
||
// Auto-promote any modal that becomes visible to the top of the z-stack.
|
||
// Every modal shares `z-index: 250` from the base `.modal` rule, so visual
|
||
// stacking falls back to DOM order — which is unpredictable (cookbook is
|
||
// a static HTML node, calendar gets appended once and stays, compare and
|
||
// research get re-appended on each open). Result: opening compare AFTER
|
||
// cookbook can render compare UNDER it. Bumping the z-index on every
|
||
// open guarantees most-recently-opened wins both visually AND for ESC.
|
||
let _zCounter = 1000;
|
||
const _isVisible = (m) => !m.classList.contains('hidden') && getComputedStyle(m).display !== 'none';
|
||
const _promote = (m) => {
|
||
if (!m?.classList?.contains('modal') || !_isVisible(m)) return;
|
||
// Re-entry guard: setting style.zIndex itself fires the observer that
|
||
// calls us back. Skip if this element is already pinned to the top
|
||
// (matches the current counter) so we don't spin into an infinite loop.
|
||
const cur = parseInt(m.style.zIndex, 10) || 0;
|
||
if (cur === _zCounter) return;
|
||
m.style.zIndex = String(++_zCounter);
|
||
};
|
||
new MutationObserver((muts) => {
|
||
for (const m of muts) {
|
||
if (m.type === 'childList') m.addedNodes.forEach(n => n.nodeType === 1 && _promote(n));
|
||
else if (m.type === 'attributes' && m.target?.classList?.contains('modal')) _promote(m.target);
|
||
}
|
||
}).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] });
|
||
document.querySelectorAll('.modal').forEach(_promote);
|
||
|
||
const pickTopModal = () => {
|
||
const modals = [...document.querySelectorAll('.modal')].filter(_isVisible);
|
||
if (!modals.length) return null;
|
||
return modals.reduce((top, m) =>
|
||
(parseInt(getComputedStyle(m).zIndex, 10) || 0) >= (parseInt(getComputedStyle(top).zIndex, 10) || 0)
|
||
? m : top
|
||
);
|
||
};
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||
const t = e.target;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||
|
||
// Find the single thing to close, in priority order. The first hit wins.
|
||
// Important: if a thinking block is open we MUST handle it ourselves and
|
||
// not fall through to closing a modal — even if its header is missing
|
||
// (the live-stream chat rebuilds thinking DOM mid-stream so the header
|
||
// can briefly be absent). Toggling the `expanded` class directly is the
|
||
// fallback so ESC never bypasses the thinking block to hit a modal.
|
||
const expanded = document.querySelector('.doclib-card-expanded');
|
||
const think = document.querySelector('.thinking-content.expanded');
|
||
if (expanded) {
|
||
e.stopImmediatePropagation(); e.preventDefault();
|
||
try { expanded.click(); } catch {}
|
||
return;
|
||
}
|
||
if (think) {
|
||
e.stopImmediatePropagation(); e.preventDefault();
|
||
const thinkHeader = think.closest('.thinking-section')?.querySelector('.thinking-header[data-thinking-id]');
|
||
if (thinkHeader) { try { thinkHeader.click(); } catch {} }
|
||
else {
|
||
// No header found — collapse the content directly.
|
||
try { think.classList.remove('expanded'); } catch {}
|
||
}
|
||
return;
|
||
}
|
||
const galleryEditor = document.getElementById('gallery-editor-container');
|
||
const galleryModal = galleryEditor?.closest('.modal');
|
||
const galleryEditing = !!(
|
||
galleryEditor &&
|
||
galleryModal &&
|
||
!galleryModal.classList.contains('hidden') &&
|
||
getComputedStyle(galleryEditor).display !== 'none' &&
|
||
galleryEditor.querySelector('.gallery-editor')
|
||
);
|
||
if (galleryEditing) {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
return;
|
||
}
|
||
const settingsModal = document.getElementById('settings-modal');
|
||
if (settingsModal && _isVisible(settingsModal)) {
|
||
const innerForm = settingsModal.querySelector('#unified-intg-form, #set-email-accounts-form');
|
||
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
innerForm.style.display = 'none';
|
||
innerForm.innerHTML = '';
|
||
return;
|
||
}
|
||
}
|
||
const topModal = pickTopModal();
|
||
if (!topModal) return;
|
||
const closeBtn = topModal.querySelector('.close-btn, .modal-close-btn, [data-action="close"]');
|
||
e.stopImmediatePropagation();
|
||
e.preventDefault();
|
||
if (closeBtn) { try { closeBtn.click(); } catch {} }
|
||
else { try { topModal.classList.add('hidden'); } catch {} }
|
||
}, true);
|
||
}
|