mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
refactor: split app.js into focused frontend modules (phase 5)
Extract 8 modules from the 15K-line app.js monolith:
- constants.js: shared constants, timing, escapeHtml(), extractSyncSegments()
- mobile-handlers.js: MobileDetection, KeyboardHandler, SwipeHandler
- voice-input.js: DeepgramProvider, VoiceInput
- notification-manager.js: NotificationManager class
- keyboard-accessory.js: KeyboardAccessoryBar, FocusTrap
- api-client.js: _api(), _apiJson(), _apiPost(), _apiDelete() helpers
- subagent-windows.js: 13 subagent window methods (open, close, drag, lines)
- vendor/xterm-zerolag-input.js: IIFE build replacing inlined copy
app.js reduced from ~15,200 to ~11,500 lines. All modules use global
scope with <script defer> ordering. Prototype extensions use
Object.assign(CodemanApp.prototype, {...}) pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
// Codeman — Mobile detection, keyboard handling, and swipe navigation
|
||||
// Loaded after constants.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* MobileDetection - Detects device type and touch capability.
|
||||
* Updates body classes for CSS targeting.
|
||||
*/
|
||||
const MobileDetection = {
|
||||
/** Check if device supports touch input */
|
||||
isTouchDevice() {
|
||||
return 'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
||||
},
|
||||
|
||||
/** Check if device is iOS (iPhone, iPad, iPod) */
|
||||
isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
},
|
||||
|
||||
/** Check if browser is Safari */
|
||||
isSafari() {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
},
|
||||
|
||||
/** Check if screen is small (phone-sized, <430px) */
|
||||
isSmallScreen() {
|
||||
return window.innerWidth < 430;
|
||||
},
|
||||
|
||||
/** Check if screen is medium (tablet-sized, 430-768px) */
|
||||
isMediumScreen() {
|
||||
return window.innerWidth >= 430 && window.innerWidth < 768;
|
||||
},
|
||||
|
||||
/** Get device type based on screen width */
|
||||
getDeviceType() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 430) return 'mobile';
|
||||
if (width < 768) return 'tablet';
|
||||
return 'desktop';
|
||||
},
|
||||
|
||||
/** Update body classes based on device detection */
|
||||
updateBodyClass() {
|
||||
const body = document.body;
|
||||
const deviceType = this.getDeviceType();
|
||||
const isTouch = this.isTouchDevice();
|
||||
|
||||
// Remove existing device classes
|
||||
body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser');
|
||||
|
||||
// Add current device class
|
||||
body.classList.add(`device-${deviceType}`);
|
||||
|
||||
// Add touch device class if applicable
|
||||
if (isTouch) {
|
||||
body.classList.add('touch-device');
|
||||
}
|
||||
|
||||
// Add iOS-specific class for safe area handling
|
||||
if (this.isIOS()) {
|
||||
body.classList.add('ios-device');
|
||||
}
|
||||
|
||||
// Add Safari class for browser-specific fixes
|
||||
if (this.isSafari()) {
|
||||
body.classList.add('safari-browser');
|
||||
}
|
||||
},
|
||||
|
||||
/** Initialize mobile detection and set up resize listener */
|
||||
init() {
|
||||
this.updateBodyClass();
|
||||
// Debounced resize handler
|
||||
let resizeTimeout;
|
||||
this._resizeHandler = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => this.updateBodyClass(), 100);
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
|
||||
// iOS: prevent pinch-to-zoom (Safari ignores user-scalable=no since iOS 10)
|
||||
if (this.isIOS()) {
|
||||
this._gestureStartHandler = (e) => e.preventDefault();
|
||||
this._gestureChangeHandler = (e) => e.preventDefault();
|
||||
document.addEventListener('gesturestart', this._gestureStartHandler);
|
||||
document.addEventListener('gesturechange', this._gestureChangeHandler);
|
||||
}
|
||||
},
|
||||
|
||||
/** Remove event listeners */
|
||||
cleanup() {
|
||||
if (this._resizeHandler) {
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
this._resizeHandler = null;
|
||||
}
|
||||
if (this._gestureStartHandler) {
|
||||
document.removeEventListener('gesturestart', this._gestureStartHandler);
|
||||
document.removeEventListener('gesturechange', this._gestureChangeHandler);
|
||||
this._gestureStartHandler = null;
|
||||
this._gestureChangeHandler = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Keyboard Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears.
|
||||
* Uses focusin event and scrollIntoView - keeps it simple and reliable.
|
||||
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
|
||||
*/
|
||||
const KeyboardHandler = {
|
||||
lastViewportHeight: 0,
|
||||
keyboardVisible: false,
|
||||
initialViewportHeight: 0,
|
||||
|
||||
/** Initialize keyboard handling */
|
||||
init() {
|
||||
// Only initialize on touch devices
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
this.initialViewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
this.lastViewportHeight = this.initialViewportHeight;
|
||||
|
||||
// Simple focus handler - scroll input into view after keyboard appears
|
||||
this._focusinHandler = (e) => {
|
||||
const target = e.target;
|
||||
if (!this.isInputElement(target)) return;
|
||||
|
||||
// Wait for keyboard animation, then scroll input into view
|
||||
setTimeout(() => {
|
||||
this.scrollInputIntoView(target);
|
||||
}, 400);
|
||||
};
|
||||
document.addEventListener('focusin', this._focusinHandler);
|
||||
|
||||
// Use visualViewport to detect keyboard and reposition toolbar
|
||||
if (window.visualViewport) {
|
||||
this._viewportResizeHandler = () => {
|
||||
this.handleViewportResize();
|
||||
};
|
||||
this._viewportScrollHandler = () => {
|
||||
this.updateLayoutForKeyboard();
|
||||
};
|
||||
window.visualViewport.addEventListener('resize', this._viewportResizeHandler);
|
||||
// Also handle scroll (iOS scrolls viewport when keyboard appears)
|
||||
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
|
||||
}
|
||||
},
|
||||
|
||||
/** Remove event listeners */
|
||||
cleanup() {
|
||||
if (this._focusinHandler) {
|
||||
document.removeEventListener('focusin', this._focusinHandler);
|
||||
this._focusinHandler = null;
|
||||
}
|
||||
if (this._viewportResizeHandler && window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', this._viewportResizeHandler);
|
||||
this._viewportResizeHandler = null;
|
||||
}
|
||||
if (this._viewportScrollHandler && window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
|
||||
this._viewportScrollHandler = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Handle viewport resize (keyboard show/hide) */
|
||||
handleViewportResize() {
|
||||
const currentHeight = window.visualViewport?.height || window.innerHeight;
|
||||
const heightDiff = this.initialViewportHeight - currentHeight;
|
||||
|
||||
// Keyboard appeared (viewport shrunk by more than 150px)
|
||||
if (heightDiff > 150 && !this.keyboardVisible) {
|
||||
this.keyboardVisible = true;
|
||||
document.body.classList.add('keyboard-visible');
|
||||
this.onKeyboardShow();
|
||||
}
|
||||
// Keyboard hidden (viewport grew back close to initial)
|
||||
// Use 100px threshold (not 50) to handle iOS address bar drift,
|
||||
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
|
||||
else if (heightDiff < 100 && this.keyboardVisible) {
|
||||
this.keyboardVisible = false;
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
}
|
||||
|
||||
// Update baseline when keyboard is not visible — adapts to address bar
|
||||
// state changes, orientation changes, and other viewport shifts
|
||||
if (!this.keyboardVisible) {
|
||||
this.initialViewportHeight = currentHeight;
|
||||
}
|
||||
|
||||
this.updateLayoutForKeyboard();
|
||||
this.lastViewportHeight = currentHeight;
|
||||
},
|
||||
|
||||
/** Update layout when keyboard shows/hides */
|
||||
updateLayoutForKeyboard() {
|
||||
if (!window.visualViewport) return;
|
||||
|
||||
// Only adjust on mobile
|
||||
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
|
||||
this.resetLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
// Calculate keyboard offset
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = layoutHeight - visualBottom;
|
||||
|
||||
// Safety: if keyboard is supposedly visible but offset is 0 or negative,
|
||||
// the keyboard is actually gone — force dismiss. This catches cases where
|
||||
// visualViewport.resize fires late or with intermediate values on iOS.
|
||||
if (keyboardOffset <= 0) {
|
||||
this.keyboardVisible = false;
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Move toolbar up above keyboard
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = `translateY(${-keyboardOffset}px)`;
|
||||
}
|
||||
|
||||
// Move accessory bar up (it sits above toolbar)
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`;
|
||||
}
|
||||
|
||||
// Shrink main content area so terminal doesn't extend behind keyboard
|
||||
// Account for keyboard height + toolbar height (40px) + accessory bar (44px)
|
||||
if (main) {
|
||||
main.style.paddingBottom = `${keyboardOffset + 94}px`;
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset layout to normal (no keyboard) */
|
||||
resetLayout() {
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
}
|
||||
},
|
||||
|
||||
/** Called when keyboard appears */
|
||||
onKeyboardShow() {
|
||||
// Show keyboard accessory bar
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') {
|
||||
KeyboardAccessoryBar.show();
|
||||
}
|
||||
|
||||
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
||||
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
||||
// old (larger) row count when the user types, causing content to scroll
|
||||
// off the visible area with each keystroke.
|
||||
// Note: the throttledResize handler still suppresses ongoing resize events
|
||||
// while keyboard is up — this one-shot resize on open/close is sufficient.
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon) try { app.fitAddon.fit(); } catch {}
|
||||
app.terminal.scrollToBottom();
|
||||
// Send resize to server so PTY dimensions match xterm
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||
},
|
||||
|
||||
/** Called when keyboard hides */
|
||||
onKeyboardHide() {
|
||||
// Hide keyboard accessory bar
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') {
|
||||
KeyboardAccessoryBar.hide();
|
||||
}
|
||||
|
||||
this.resetLayout();
|
||||
|
||||
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.fitAddon) {
|
||||
try { app.fitAddon.fit(); } catch {}
|
||||
if (app.terminal) app.terminal.scrollToBottom();
|
||||
// Send resize to server to restore full terminal size
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Reposition subagent windows to stack from top (below header)
|
||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||
},
|
||||
|
||||
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
|
||||
_sendTerminalResize() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
||||
try {
|
||||
const dims = app.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
const cols = Math.max(dims.cols, 40);
|
||||
const rows = Math.max(dims.rows, 10);
|
||||
app._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows })
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/** Check if element is an input that triggers keyboard (excludes terminal) */
|
||||
isInputElement(el) {
|
||||
if (!el) return false;
|
||||
|
||||
// Exclude xterm.js terminal inputs (they handle their own scroll)
|
||||
if (el.closest('.xterm') || el.closest('.terminal-container')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagName = el.tagName?.toLowerCase();
|
||||
// Exclude type=range, type=checkbox, type=radio (don't trigger keyboard)
|
||||
if (tagName === 'input') {
|
||||
const type = el.type?.toLowerCase();
|
||||
if (type === 'checkbox' || type === 'radio' || type === 'range' || type === 'file') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
el.isContentEditable
|
||||
);
|
||||
},
|
||||
|
||||
/** Scroll input into view above the keyboard */
|
||||
scrollInputIntoView(input) {
|
||||
// Check if input is still focused (user might have tapped away)
|
||||
if (document.activeElement !== input) return;
|
||||
|
||||
// Find if we're in a modal
|
||||
const modal = input.closest('.modal.active');
|
||||
const modalBody = modal?.querySelector('.modal-body');
|
||||
|
||||
if (modalBody) {
|
||||
// For modals - scroll within the modal body
|
||||
const inputRect = input.getBoundingClientRect();
|
||||
const modalRect = modalBody.getBoundingClientRect();
|
||||
|
||||
// If input is below middle of modal, scroll it up
|
||||
if (inputRect.top > modalRect.top + modalRect.height * 0.4) {
|
||||
const scrollAmount = inputRect.top - modalRect.top - 100;
|
||||
modalBody.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
} else {
|
||||
// For page-level - use scrollIntoView
|
||||
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Swipe Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SwipeHandler - Detects horizontal swipes on terminal to switch sessions.
|
||||
* Only active on mobile/touch devices.
|
||||
*/
|
||||
const SwipeHandler = {
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 100, // Max vertical movement allowed
|
||||
|
||||
/** Initialize swipe handling */
|
||||
init() {
|
||||
// Only on touch devices
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
const terminal = document.querySelector('.main');
|
||||
if (!terminal) return;
|
||||
|
||||
terminal.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true });
|
||||
terminal.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
|
||||
},
|
||||
|
||||
onTouchStart(e) {
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
this.startX = e.touches[0].clientX;
|
||||
this.startY = e.touches[0].clientY;
|
||||
this.startTime = Date.now();
|
||||
},
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (!e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
|
||||
// Check if it's a valid swipe
|
||||
const deltaX = endX - this.startX;
|
||||
const deltaY = Math.abs(endY - this.startY);
|
||||
|
||||
if (elapsed > this.maxSwipeTime) return; // Too slow
|
||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||
|
||||
// Valid swipe detected
|
||||
if (deltaX > 0) {
|
||||
// Swipe right -> previous session
|
||||
if (typeof app !== 'undefined') app.prevSession();
|
||||
} else {
|
||||
// Swipe left -> next session
|
||||
if (typeof app !== 'undefined') app.nextSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user