UNPKG

on-screen-keyboard-detector

Version:

Detects presence of the On-Screen-Keyboard in mobile browsers

194 lines (167 loc) 6.39 kB
/** * onscreen-keyboard-detector: osk-detector.js * * Created by Matthias Seemann on 21.03.2020. */ import { at, debounce, delay, empty, filter, join, map, merge, mergeArray, multicast, now, runEffects, scan, skipAfter, skipRepeats, snapshot, switchLatest, startWith, tap, until } from '@most/core'; import { newDefaultScheduler } from '@most/scheduler'; import { change, domEvent, focusin, focusout, resize } from '@most/dom-event'; import { createAdapter } from '@most/adapter'; import {always, assoc, applyTo, compose, curry, difference, equals, pipe, isEmpty, identical, keys, propEq} from 'ramda'; import {subscribe as subscribeOnIOS, isSupported as isSupportedOnIOS} from './oskd-ios.js'; const userAgent = navigator.userAgent, isTouchable = "ontouchend" in document, isIPad = /\b(\w*Macintosh\w*)\b/.test(userAgent) && isTouchable, isIPhone = /\b(\w*iPhone\w*)\b/.test(userAgent) && /\b(\w*Mobile\w*)\b/.test(userAgent) && isTouchable, isIOS = isIPad || isIPhone, getScreenOrientationType = () => screen.orientation.type.startsWith('portrait') ? 'portrait' : 'landscape', // rejectCapture :: Stream Boolean -> Stream a -> Stream a rejectCapture = curry(compose(join, snapshot((valveValue, event) => valveValue ? empty() : now(event)))), isAnyElementActive = () => document.activeElement && (document.activeElement !== document.body); function isSupported() { if (isIOS) { return isSupportedOnIOS(); } return isTouchable; } /** * * @param {function(String)} userCallback * @return {function(): void} */ // initWithCallback :: (String -> *) -> (... -> undefined) function initWithCallback(userCallback) { if(isIOS) { return subscribeOnIOS(userCallback); } const INPUT_ELEMENT_FOCUS_JUMP_DELAY = 700, SCREEN_ORIENTATION_TO_WINDOW_RESIZE_DELAY = 700, RESIZE_QUIET_PERIOD = 500, LAYOUT_RESIZE_TO_LAYOUT_HEIGHT_FIX_DELAY = Math.max(INPUT_ELEMENT_FOCUS_JUMP_DELAY, SCREEN_ORIENTATION_TO_WINDOW_RESIZE_DELAY) - RESIZE_QUIET_PERIOD + 200, [ induceUnsubscribe, userUnsubscription ] = createAdapter(), scheduler = newDefaultScheduler(), // assumes initially hidden OSK initialLayoutHeight = window.innerHeight, // assumes initially hidden OSK approximateBrowserToolbarHeight = screen.availHeight - window.innerHeight, // Implementation note: // On Chrome window.outerHeight changes together with window.innerHeight // They seem to be always equal to each other. focus = merge(focusin(document.documentElement), focusout(document.documentElement)), documentVisibility = applyTo(domEvent('visibilitychange', document))(pipe( map(() => document.visibilityState), startWith(document.visibilityState) )), isUnfocused = applyTo(focus)(pipe( map(evt => evt.type === 'focusin' ? now(false) : at(INPUT_ELEMENT_FOCUS_JUMP_DELAY, true) ), switchLatest, startWith(!isAnyElementActive()), skipRepeats, multicast )), layoutHeightOnOSKFreeOrientationChange = applyTo(change(screen.orientation))(pipe( // The 'change' event hits very early BEFORE window.innerHeight is updated (e.g. on "resize") snapshot( unfocused => unfocused || (window.innerHeight === initialLayoutHeight), isUnfocused ), debounce(SCREEN_ORIENTATION_TO_WINDOW_RESIZE_DELAY), map(isOSKFree => ({ screenOrientation: getScreenOrientationType(), height: isOSKFree ? window.innerHeight : screen.availHeight - approximateBrowserToolbarHeight })) )), layoutHeightOnUnfocus = applyTo(isUnfocused)(pipe( filter(identical(true)), map(() => ({screenOrientation: getScreenOrientationType(), height: window.innerHeight})) )), // Difficulties: The exact layout height in the perpendicular orientation is only to determine on orientation change, // Orientation change can happen: // - entirely unfocused, // - focused but w/o OSK, or // - with OSK. // Thus on arriving in the new orientation, until complete unfocus, it is uncertain what the current window.innerHeight value means // Solution?: Assume initially hidden OSK (even if any input has the "autofocus" attribute), // and initialize other dimension with screen.availWidth // so there can always be made a decision on the keyboard. layoutHeights = // Ignores source streams while documentVisibility is 'hidden' // sadly visibilitychange comes 1 sec after focusout! applyTo(mergeArray([layoutHeightOnUnfocus, layoutHeightOnOSKFreeOrientationChange]))(pipe( delay(1000), rejectCapture(map(equals("hidden"), documentVisibility)), scan( (accHeights, {screenOrientation, height}) => assoc(screenOrientation, height, accHeights), { [getScreenOrientationType()]: window.innerHeight } ), skipAfter(compose(isEmpty, difference(['portrait', 'landscape']), keys)) )), layoutHeightOnVerticalResize = applyTo(resize(window))(pipe( debounce(RESIZE_QUIET_PERIOD), map(evt => ({ width: evt.target.innerWidth, height: evt.target.innerHeight})), scan( (prev, size) => ({ ...size, isJustHeightResize: prev.width === size.width, dH: size.height - prev.height }), { width: window.innerWidth, height: window.innerHeight, isJustHeightResize: false, dH: 0 } ), filter(propEq('isJustHeightResize', true)) )), osk = applyTo(layoutHeightOnVerticalResize)(pipe( delay(LAYOUT_RESIZE_TO_LAYOUT_HEIGHT_FIX_DELAY), snapshot( (layoutHeightByOrientation, {height, dH}) => { const nonOSKLayoutHeight = layoutHeightByOrientation[getScreenOrientationType()]; if (!nonOSKLayoutHeight) { return (dH > 0.1 * screen.availHeight) ? now("hidden") : (dH < -0.1 * screen.availHeight) ? now("visible") : empty(); } return (height < 0.9 * nonOSKLayoutHeight) && (dH < 0) ? now("visible") : (height === nonOSKLayoutHeight) && (dH > 0) ? now("hidden") : empty(); }, layoutHeights ), join, merge(applyTo(isUnfocused)(pipe( filter(identical(true)), map(always("hidden")) ))), until(userUnsubscription), skipRepeats )); runEffects(tap(userCallback, osk), scheduler); return induceUnsubscribe; } export { initWithCallback as subscribe, isSupported };