fix(ui): respect iOS safe areas, screen height and landscape layout

This commit is contained in:
Gr33nbl00d
2026-10-19 19:43:53 +02:00
parent 88772b3278
commit b2613b2106
20 changed files with 243 additions and 95 deletions
-7
View File
@@ -6929,13 +6929,6 @@ import { invalidateSettings } from './appConfig.js';
}
});
}
// Smooth viewport resize when keyboard opens/closes
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
});
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
}
}
// If the browser discarded and restored this tab, reload the current session
+5 -4
View File
@@ -22,7 +22,7 @@ import {
} from './utils.js';
import { state } from './state.js';
import { getSettings } from '../appConfig.js';
import { collapseSidebarToRail } from '../modalSnap.js';
import { clampWindowLeft, collapseSidebarToRail, leftNavRight, safeInsetPx } from '../modalSnap.js';
import { emailApiUrl } from '../emailShared.js';
import {
bindMenuDismiss,
@@ -823,7 +823,7 @@ function _readCssPx(name) {
}
function _emailSplitLeftEdge() {
return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w');
return leftNavRight();
}
function _setEmailDocumentSplit(leftEdge, emailWidth) {
@@ -899,7 +899,8 @@ function _clearEmailDocumentSplit() {
// rail. Used by the "try collapsing the sidebar first" path so we can decide
// whether collapsing recovers enough room before minimizing email.
function _emailSplitLeftEdgeIfSidebarCollapsed() {
return _readCssPx('--icon-rail-w');
const navOnRight = document.getElementById('sidebar')?.classList.contains('right-side');
return safeInsetPx('left') + (navOnRight ? 0 : _readCssPx('--icon-rail-w'));
}
function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) {
@@ -2415,7 +2416,7 @@ export function openEmailLibrary(opts = {}) {
requestAnimationFrame(() => {
const w = content.offsetWidth;
const refH = window.innerHeight * 0.85;
content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px';
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2), w) + 'px';
content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px';
content.style.transform = 'none';
});
+2 -1
View File
@@ -11,6 +11,7 @@
import spinnerModule from '../spinner.js';
import * as Modals from '../modalManager.js';
import { clampWindowLeft } from '../modalSnap.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
@@ -428,7 +429,7 @@ export async function _openEmailWindow(em, folder) {
requestAnimationFrame(() => {
const w = content.offsetWidth, h = content.offsetHeight;
const off = (_emailWindowSeq % 6) * 28;
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2 + off), w) + 'px';
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
});
}
+1 -1
View File
@@ -190,7 +190,7 @@ export function createPanel({
// which left it as a side panel squeezing the chat).
if (window.innerWidth <= 768) {
pane.style.position = 'fixed';
pane.style.inset = '0';
pane.style.inset = 'var(--sheet-top) 0 0';
pane.style.width = '100%';
pane.style.maxWidth = '100%';
pane.style.zIndex = '170';
+25
View File
@@ -369,6 +369,31 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
/* ---- Mobile viewport fix — keep chat visible when virtual keyboard opens ---- */
{
if (window.visualViewport) {
const root = document.documentElement;
// iOS overlays the keyboard without resizing the layout viewport and scrolls
// the page to reveal the field, leaving it shifted after the keyboard closes.
const _syncKeyboardViewport = function() {
const vv = window.visualViewport;
if (vv.scale > 1.01) return;
const keyboardOpen = window.innerHeight - vv.height > 120;
root.classList.toggle('keyboard-open', keyboardOpen);
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
else root.style.removeProperty('--keyboard-viewport-height');
if (window.scrollY) window.scrollTo(0, 0);
};
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
// the tapped field already had focus (e.g. the composer after a new chat)
const _syncThroughKeyboardAnimation = () => {
_syncKeyboardViewport();
for (const delay of [100, 300, 600, 1000]) setTimeout(_syncKeyboardViewport, delay);
};
_syncKeyboardViewport();
window.visualViewport.addEventListener('resize', _syncThroughKeyboardAnimation);
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() {
const vv = window.visualViewport;
+45 -7
View File
@@ -110,7 +110,7 @@ function _clampRightDockWidth(width) {
const min = _minEdgeDockWidth();
const navRight = _leftNavRight();
const leftDockW = _activeDockWidth('left');
const maxByChat = window.innerWidth - navRight - leftDockW - MIN_CHAT_WIDTH;
const maxByChat = window.innerWidth - navRight - _rightNavInset() - leftDockW - MIN_CHAT_WIDTH;
const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat);
return _clampDockWidthToSpace(width, min, max);
}
@@ -118,7 +118,7 @@ function _clampRightDockWidth(width) {
function _clampLeftDockWidth(width, left = _leftNavRight()) {
const min = _minEdgeDockWidth();
const rightDockW = _activeDockWidth('right');
const available = Math.max(0, window.innerWidth - left - rightDockW);
const available = Math.max(0, window.innerWidth - left - _rightNavInset() - rightDockW);
const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH);
return _clampDockWidthToSpace(width, min, max);
}
@@ -170,13 +170,23 @@ function _shouldAutoCollapseSidebar(dockW) {
return remaining < MIN_CHAT_WIDTH;
}
function _navOnRight() {
return !!document.getElementById('sidebar')?.classList.contains('right-side');
}
// Body padding carries the device safe-area insets (notch, Dynamic Island).
function _bodyPaddingPx(side) {
return parseFloat(window.getComputedStyle(document.body)[side === 'left' ? 'paddingLeft' : 'paddingRight']) || 0;
}
// Right edge (px) of whatever left navigation is currently showing — the
// expanded sidebar if visible, otherwise the icon rail. Used to anchor the
// left dock so it always sits flush to the right of the nav.
function _leftNavRight() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
let x = 0;
let x = _bodyPaddingPx('left');
if (_navOnRight()) return x;
if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect();
if (r.width) x = Math.max(x, r.right);
@@ -188,8 +198,36 @@ function _leftNavRight() {
return x;
}
// Distance (px) from the viewport's right edge that a right-side nav and the
// safe area occupy. The right dock sits flush to the left of it.
function _rightNavInset() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
let inset = _bodyPaddingPx('right');
if (!_navOnRight()) return inset;
if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect();
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
}
if (rail && window.getComputedStyle(rail).display !== 'none') {
const r = rail.getBoundingClientRect();
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
}
return inset;
}
export function leftNavRight() { return _leftNavRight(); }
export function rightNavInset() { return _rightNavInset(); }
export function safeInsetPx(side) { return _bodyPaddingPx(side); }
// Keeps a floating window between the nav and the safe areas
export function clampWindowLeft(left, width) {
const max = window.innerWidth - _rightNavInset() - width;
return Math.max(_leftNavRight(), Math.min(left, max));
}
function _clampEmailDocSplitWidth(width, left = _leftNavRight()) {
const available = Math.max(0, window.innerWidth - left);
const available = Math.max(0, window.innerWidth - left - _rightNavInset());
if (!available) return 0;
const compact = available < 760;
const minEmail = compact ? 260 : 340;
@@ -235,7 +273,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) {
if (!docPane || window.innerWidth <= 768) return;
docPane.style.setProperty('position', 'fixed', 'important');
docPane.style.setProperty('left', `${x}px`, 'important');
docPane.style.setProperty('right', 'var(--right-dock-w, 0px)', 'important');
docPane.style.setProperty('right', `calc(var(--right-dock-w, 0px) + ${_rightNavInset()}px)`, 'important');
docPane.style.setProperty('top', '0px', 'important');
docPane.style.setProperty('bottom', '0px', 'important');
docPane.style.setProperty('width', 'auto', 'important');
@@ -497,7 +535,7 @@ function _applyDockInternal(modal, side, dockClass) {
} else {
w = _resolveRightDockWidth(modal, content);
content.style.left = 'auto';
content.style.right = '0';
content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
@@ -872,7 +910,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
w = _clampRightDockWidth(window.innerWidth - clientX);
content._userDockWidth = w;
content.style.left = 'auto';
content.style.right = '0';
content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
+5 -1
View File
@@ -75,12 +75,15 @@ export function initSidebarLayout(Storage, opts) {
_setSidebarModeClasses(mode);
}
// Phones in landscape get the desktop layout, but the icon rail is too small to use there
const _phoneLandscape = window.matchMedia('(pointer: coarse) and (max-height: 500px)');
function _syncRailSideCore() {
const sidebar = document.getElementById('sidebar');
if (!iconRail) return;
const isRight = sidebar.classList.contains('right-side');
const sidebarHidden = sidebar.classList.contains('hidden');
const railHidden = iconRail.classList.contains('rail-hidden');
const railHidden = iconRail.classList.contains('rail-hidden') || _phoneLandscape.matches;
const isMobileMini = iconRail.classList.contains('mobile-mini');
iconRail.classList.toggle('right-side', isRight);
// On mobile mini mode, JS already set inline styles — don't touch
@@ -118,6 +121,7 @@ export function initSidebarLayout(Storage, opts) {
// Set initial reference and expose globally
_syncRailSideFn = _syncRailSideCore;
window.syncRailSide = syncRailSide;
_phoneLandscape.addEventListener('change', () => syncRailSide());
// Restore sidebar side preference
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
+8 -1
View File
@@ -257,12 +257,19 @@ export function makeWindowResizable(content, options = {}) {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved && saved.w && saved.h) {
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
const bodyStyle = getComputedStyle(document.body);
const padLeft = parseFloat(bodyStyle.paddingLeft) || 0;
const padRight = parseFloat(bodyStyle.paddingRight) || 0;
const w = Math.max(minW, Math.min(saved.w, window.innerWidth - padLeft - padRight));
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
content.style.width = w + 'px';
content.style.height = h + 'px';
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
const left = parseFloat(content.style.left);
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
}
}
} catch (_) {}
});