diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 74e9cdf..027e432 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -7,6 +7,7 @@ import { setupRouterGuards } from '@/app/setupRouter' import { useSettingsStore } from '@/store/settingsStore' import { initPriceRealtimeChannel } from '@/utils/priceSocket' import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset' +import { initMobileInputKeyboardScroll } from '@/utils/mobileInputKeyboardScroll' type BootWindow = Window & { __BOOT_TIMEOUT__?: number @@ -76,6 +77,7 @@ export async function bootstrapApp() { initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key)) initMobileScrollRestoration() + initMobileInputKeyboardScroll() setupRouterGuards() app.mount('#app') diff --git a/src/assets/css/main.less b/src/assets/css/main.less index b2b44b3..33a3781 100644 --- a/src/assets/css/main.less +++ b/src/assets/css/main.less @@ -13,6 +13,7 @@ @import (less) '../../views/base/gold/desktop/styles/desktop-gold.less'; @import (less) '../../views/login/desktop/styles/desktop-login.less'; @import (less) '../../layouts/desktop/styles/desktop-inner-pages.less'; +@import (less) './mobile-keyboard-scroll.less'; html { scroll-behavior: smooth; diff --git a/src/assets/css/mobile-keyboard-scroll.less b/src/assets/css/mobile-keyboard-scroll.less new file mode 100644 index 0000000..94f6bbc --- /dev/null +++ b/src/assets/css/mobile-keyboard-scroll.less @@ -0,0 +1,51 @@ +:root { + --app-keyboard-inset: 0px; + --app-visual-viewport-height: 100dvh; + --app-visual-viewport-offset-top: 0px; +} + +@media screen and (max-width: 767.98px) { + input, + textarea, + select, + .ant-input, + .ant-input-affix-wrapper, + .ant-select-selection-search-input { + scroll-margin-bottom: calc(var(--app-keyboard-inset) + 16px); + } + + html[data-keyboard-open='true'] { + .main-with-bottom-chrome, + .login-vip-page__body, + .login-page, + .login-page-root, + .ant-modal-body, + .ant-drawer-body, + .van-action-sheet__content, + .basic-action-sheet__body { + scroll-padding-bottom: calc(var(--app-keyboard-inset) + 16px); + } + + .main-with-bottom-chrome, + .login-vip-page__body, + .login-page { + padding-bottom: var(--app-keyboard-inset); + } + + .login-mobile { + height: var(--app-visual-viewport-height, 100dvh); + max-height: var(--app-visual-viewport-height, 100dvh); + } + + .login-page.flex { + height: var(--app-visual-viewport-height, 92vh) !important; + max-height: var(--app-visual-viewport-height, 92vh) !important; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + align-items: flex-start !important; + justify-content: flex-start !important; + padding-top: 0.75rem; + } + } +} diff --git a/src/utils/mobileInputKeyboardScroll.ts b/src/utils/mobileInputKeyboardScroll.ts new file mode 100644 index 0000000..93f2537 --- /dev/null +++ b/src/utils/mobileInputKeyboardScroll.ts @@ -0,0 +1,176 @@ +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) + }) + } +}