mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix: prevent mobile page scroll when typing with keyboard open
iOS Safari scrolls the document to bring xterm's hidden textarea into view when the user types, pushing the entire UI off-screen. Fix with: - CSS position:fixed on .app when keyboard is visible - window.scroll listener to reset scroll position as safety net - scroll reset in onKeyboardShow before and after fit/resize Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,15 +36,19 @@
|
|||||||
const MobileDetection = {
|
const MobileDetection = {
|
||||||
/** Check if device supports touch input */
|
/** Check if device supports touch input */
|
||||||
isTouchDevice() {
|
isTouchDevice() {
|
||||||
return 'ontouchstart' in window ||
|
return (
|
||||||
|
'ontouchstart' in window ||
|
||||||
navigator.maxTouchPoints > 0 ||
|
navigator.maxTouchPoints > 0 ||
|
||||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Check if device is iOS (iPhone, iPad, iPod) */
|
/** Check if device is iOS (iPhone, iPad, iPod) */
|
||||||
isIOS() {
|
isIOS() {
|
||||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
return (
|
||||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Check if browser is Safari */
|
/** Check if browser is Safari */
|
||||||
@@ -77,7 +81,14 @@ const MobileDetection = {
|
|||||||
const isTouch = this.isTouchDevice();
|
const isTouch = this.isTouchDevice();
|
||||||
|
|
||||||
// Remove existing device classes
|
// Remove existing device classes
|
||||||
body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser');
|
body.classList.remove(
|
||||||
|
'device-mobile',
|
||||||
|
'device-tablet',
|
||||||
|
'device-desktop',
|
||||||
|
'touch-device',
|
||||||
|
'ios-device',
|
||||||
|
'safari-browser'
|
||||||
|
);
|
||||||
|
|
||||||
// Add current device class
|
// Add current device class
|
||||||
body.classList.add(`device-${deviceType}`);
|
body.classList.add(`device-${deviceType}`);
|
||||||
@@ -153,7 +164,7 @@ const MobileDetection = {
|
|||||||
this._gestureStartHandler = null;
|
this._gestureStartHandler = null;
|
||||||
this._gestureChangeHandler = null;
|
this._gestureChangeHandler = null;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -202,6 +213,17 @@ const KeyboardHandler = {
|
|||||||
// Also handle scroll (iOS scrolls viewport when keyboard appears)
|
// Also handle scroll (iOS scrolls viewport when keyboard appears)
|
||||||
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
|
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent page-level scroll when keyboard is visible.
|
||||||
|
// iOS Safari scrolls the document to bring xterm's hidden textarea into
|
||||||
|
// view when the user types, pushing the entire UI off-screen. The CSS
|
||||||
|
// position:fixed on .app prevents most cases, but reset as a safety net.
|
||||||
|
this._windowScrollHandler = () => {
|
||||||
|
if (this.keyboardVisible) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', this._windowScrollHandler);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Remove event listeners */
|
/** Remove event listeners */
|
||||||
@@ -218,6 +240,10 @@ const KeyboardHandler = {
|
|||||||
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
|
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
|
||||||
this._viewportScrollHandler = null;
|
this._viewportScrollHandler = null;
|
||||||
}
|
}
|
||||||
|
if (this._windowScrollHandler) {
|
||||||
|
window.removeEventListener('scroll', this._windowScrollHandler);
|
||||||
|
this._windowScrollHandler = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Handle viewport resize (keyboard show/hide) */
|
/** Handle viewport resize (keyboard show/hide) */
|
||||||
@@ -327,6 +353,10 @@ const KeyboardHandler = {
|
|||||||
KeyboardAccessoryBar.show();
|
KeyboardAccessoryBar.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset any page scroll that occurred during keyboard open.
|
||||||
|
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
||||||
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
||||||
// old (larger) row count when the user types, causing content to scroll
|
// old (larger) row count when the user types, causing content to scroll
|
||||||
@@ -335,11 +365,16 @@ const KeyboardHandler = {
|
|||||||
// while keyboard is up — this one-shot resize on open/close is sufficient.
|
// while keyboard is up — this one-shot resize on open/close is sufficient.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
if (app.fitAddon) try { app.fitAddon.fit(); } catch {}
|
if (app.fitAddon)
|
||||||
|
try {
|
||||||
|
app.fitAddon.fit();
|
||||||
|
} catch {}
|
||||||
app.terminal.scrollToBottom();
|
app.terminal.scrollToBottom();
|
||||||
// Send resize to server so PTY dimensions match xterm
|
// Send resize to server so PTY dimensions match xterm
|
||||||
this._sendTerminalResize();
|
this._sendTerminalResize();
|
||||||
}
|
}
|
||||||
|
// Reset again after fit/resize in case layout changes triggered scroll
|
||||||
|
window.scrollTo(0, 0);
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||||
@@ -358,7 +393,9 @@ const KeyboardHandler = {
|
|||||||
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof app !== 'undefined' && app.fitAddon) {
|
if (typeof app !== 'undefined' && app.fitAddon) {
|
||||||
try { app.fitAddon.fit(); } catch {}
|
try {
|
||||||
|
app.fitAddon.fit();
|
||||||
|
} catch {}
|
||||||
if (app.terminal) app.terminal.scrollToBottom();
|
if (app.terminal) app.terminal.scrollToBottom();
|
||||||
// Send resize to server to restore full terminal size
|
// Send resize to server to restore full terminal size
|
||||||
this._sendTerminalResize();
|
this._sendTerminalResize();
|
||||||
@@ -381,7 +418,7 @@ const KeyboardHandler = {
|
|||||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ cols, rows })
|
body: JSON.stringify({ cols, rows }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -404,11 +441,7 @@ const KeyboardHandler = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return tagName === 'input' || tagName === 'textarea' || el.isContentEditable;
|
||||||
tagName === 'input' ||
|
|
||||||
tagName === 'textarea' ||
|
|
||||||
el.isContentEditable
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Scroll input into view above the keyboard */
|
/** Scroll input into view above the keyboard */
|
||||||
@@ -434,7 +467,7 @@ const KeyboardHandler = {
|
|||||||
// For page-level - use scrollIntoView
|
// For page-level - use scrollIntoView
|
||||||
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -449,8 +482,8 @@ const SwipeHandler = {
|
|||||||
startX: 0,
|
startX: 0,
|
||||||
startY: 0,
|
startY: 0,
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||||
maxVerticalDrift: 100, // Max vertical movement allowed
|
maxVerticalDrift: 100, // Max vertical movement allowed
|
||||||
|
|
||||||
_touchStartHandler: null,
|
_touchStartHandler: null,
|
||||||
@@ -501,9 +534,9 @@ const SwipeHandler = {
|
|||||||
const deltaX = endX - this.startX;
|
const deltaX = endX - this.startX;
|
||||||
const deltaY = Math.abs(endY - this.startY);
|
const deltaY = Math.abs(endY - this.startY);
|
||||||
|
|
||||||
if (elapsed > this.maxSwipeTime) return; // Too slow
|
if (elapsed > this.maxSwipeTime) return; // Too slow
|
||||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||||
|
|
||||||
// Valid swipe detected
|
// Valid swipe detected
|
||||||
if (deltaX > 0) {
|
if (deltaX > 0) {
|
||||||
@@ -513,5 +546,5 @@ const SwipeHandler = {
|
|||||||
// Swipe left -> next session
|
// Swipe left -> next session
|
||||||
if (typeof app !== 'undefined') app.nextSession();
|
if (typeof app !== 'undefined') app.nextSession();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -428,6 +428,18 @@ html.mobile-init .file-browser-panel {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When keyboard is visible, pin the app container to the viewport.
|
||||||
|
iOS Safari scrolls the page to bring the focused input (xterm's hidden
|
||||||
|
textarea) into view, even with overflow:hidden. position:fixed prevents
|
||||||
|
the browser from scrolling the document under the app. */
|
||||||
|
.keyboard-visible .app {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
|
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
|
||||||
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
|
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
|
||||||
.session-tabs,
|
.session-tabs,
|
||||||
|
|||||||
Reference in New Issue
Block a user