const MOBILE_MEDIA = '(max-width: 767.98px)' const KEYBOARD_GAP_PX = 16 const SCROLL_RETRY_MS = [0, 90, 220, 420, 640] let started = false let activeField: HTMLElement | null = null let blurTimer = 0 function isMobileLayout(): boolean { return window.matchMedia(MOBILE_MEDIA).matches } function getKeyboardInset(): number { const vv = window.visualViewport if (!vv) return 0 return Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) } function getVisibleViewportBottom(): number { const vv = window.visualViewport return vv ? vv.offsetTop + vv.height : window.innerHeight } function syncViewportVars(hasFocusedField: boolean) { const vv = window.visualViewport const inset = getKeyboardInset() document.documentElement.style.setProperty('--app-keyboard-inset', `${inset}px`) if (vv) { document.documentElement.style.setProperty('--app-visual-viewport-height', `${Math.round(vv.height)}px`) document.documentElement.style.setProperty('--app-visual-viewport-offset-top', `${Math.round(vv.offsetTop)}px`) } if (hasFocusedField && inset > 0) { document.documentElement.dataset.keyboardOpen = 'true' return } if (!hasFocusedField) { delete document.documentElement.dataset.keyboardOpen document.documentElement.style.setProperty('--app-keyboard-inset', '0px') } } function resolveEditableTarget(node: EventTarget | null): HTMLElement | null { if (!(node instanceof HTMLElement)) return null const field = node.closest('input, textarea, select, [contenteditable="true"]') if (!(field instanceof HTMLElement)) return null if (field instanceof HTMLInputElement) { if (field.disabled || field.readOnly || field.type === 'hidden') return null } if (field instanceof HTMLTextAreaElement) { if (field.disabled || field.readOnly) return null } if (field instanceof HTMLSelectElement && field.disabled) return null return field } function findScrollableAncestor(el: HTMLElement): HTMLElement | null { let node: HTMLElement | null = el.parentElement while (node && node !== document.body && node !== document.documentElement) { const style = window.getComputedStyle(node) const overflowY = style.overflowY const scrollable = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay' if (scrollable && node.scrollHeight > node.clientHeight + 1) { return node } node = node.parentElement } return null } function ensureFieldVisible(field: HTMLElement) { if (!isMobileLayout()) return syncViewportVars(true) const rect = field.getBoundingClientRect() const visibleBottom = getVisibleViewportBottom() - KEYBOARD_GAP_PX const scrollParent = findScrollableAncestor(field) if (rect.bottom > visibleBottom) { const delta = rect.bottom - visibleBottom if (scrollParent) { scrollParent.scrollTop += delta } else { window.scrollBy({top: delta, left: 0, behavior: 'auto'}) } } if (rect.top < KEYBOARD_GAP_PX && scrollParent) { scrollParent.scrollTop += rect.top - KEYBOARD_GAP_PX } field.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'auto'}) } function scheduleEnsureVisible(field: HTMLElement) { SCROLL_RETRY_MS.forEach((delay) => { window.setTimeout(() => { const active = resolveEditableTarget(document.activeElement) if (!active || active !== field) return ensureFieldVisible(field) }, delay) }) } function onFocusIn(event: FocusEvent) { if (!isMobileLayout()) return const field = resolveEditableTarget(event.target) if (!field) return activeField = field window.clearTimeout(blurTimer) scheduleEnsureVisible(field) } function onFocusOut() { window.clearTimeout(blurTimer) blurTimer = window.setTimeout(() => { const next = resolveEditableTarget(document.activeElement) activeField = next if (!next) { syncViewportVars(false) return } syncViewportVars(true) scheduleEnsureVisible(next) }, 120) } function onViewportChange() { if (!isMobileLayout()) return syncViewportVars(Boolean(activeField)) const active = resolveEditableTarget(document.activeElement) ?? activeField if (!active) return activeField = active ensureFieldVisible(active) } /** موبایل: با باز شدن کیبورد، فیلد فوکوس‌شده بالای کیبورد دیده شود. */ export function initMobileInputKeyboardScroll() { if (typeof window === 'undefined' || started) return started = true document.addEventListener('focusin', onFocusIn, true) document.addEventListener('focusout', onFocusOut, true) window.visualViewport?.addEventListener('resize', onViewportChange, {passive: true}) window.visualViewport?.addEventListener('scroll', onViewportChange, {passive: true}) if (import.meta.hot) { import.meta.hot.dispose(() => { started = false document.removeEventListener('focusin', onFocusIn, true) document.removeEventListener('focusout', onFocusOut, true) window.visualViewport?.removeEventListener('resize', onViewportChange) window.visualViewport?.removeEventListener('scroll', onViewportChange) }) } }