From 08c284061a72f1f74f4dfa5e8ccb79f79661ee59 Mon Sep 17 00:00:00 2001 From: Dannii Willis Date: Sun, 27 Oct 2024 15:43:44 +1000 Subject: [PATCH] Hopefully a final solution for the soft keyboard in iOS. We manually set the gameport height in input blur/focus event handlers. Then when the visualViewport:resize event arrives later on, it should ideally be already the same height, or else very close to it. I discovered that iOS actually sends 3 resize events on focus, for the keyboard being up, then down, then up again. So I throttle the resize handler in iOS. Use the body-scroll-lock package to handle scrolling in iOS, letting me remove a bunch of old scrolling code. Note that the body and html elements need `height: 100%; width: 100%` CSS to be applied. The TextInput.refocus() function was being called multiple times when the soft keyboard changed. Prevent it from being called more than once after each turn. --- package.json | 2 ++ src/glkote/web/input.ts | 58 +++++++++++++++++++-------------------- src/glkote/web/metrics.ts | 25 ++++++++++++++--- src/glkote/web/shared.ts | 12 +++++++- src/glkote/web/web.ts | 15 ++-------- src/glkote/web/windows.ts | 16 ++++++++++- 6 files changed, 80 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 07a9b31..4969e67 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "type": "module", "dependencies": { "base32768": "^3.0.1", + "body-scroll-lock": "^4.0.0-beta.0", "file-saver": "^2.0.5", "lodash-es": "^4.17.21", "mute-stream": "2.0.0", "path-browserify-esm": "^1.0.6" }, "devDependencies": { + "@types/body-scroll-lock": "^3.1.2", "@types/file-saver": "^2.0.7", "@types/jquery": "^3.5.31", "@types/lodash-es": "^4.17.12", diff --git a/src/glkote/web/input.ts b/src/glkote/web/input.ts index 63a6c2c..6882573 100644 --- a/src/glkote/web/input.ts +++ b/src/glkote/web/input.ts @@ -8,12 +8,14 @@ MIT licenced https://github.com/curiousdannii/asyncglk */ -import {throttle} from 'lodash-es' + +import {debounce} from 'lodash-es' import {KEY_CODE_DOWN, KEY_CODE_RETURN, KEY_CODE_UP, KEY_CODES_TO_NAMES, OFFSCREEN_OFFSET} from '../../common/constants.js' import {is_pinch_zoomed} from '../../common/misc.js' import * as protocol from '../../common/protocol.js' +import {is_input_focused, is_iOS} from './shared.js' import {apply_text_run_styles, type Window} from './windows.js' const MAX_HISTORY_LENGTH = 25 @@ -22,10 +24,12 @@ export class TextInput { el: JQuery history_index = 0 is_line = false + /** Whether this input has been refocused since it was last reset */ + refocused = false window: Window - constructor(window: Window) { - this.window = window + constructor(win: Window) { + this.window = win // We use a textarea rather than an input because mobile Chrome shows an extra bar which can't be removed // See https://github.com/curiousdannii/asyncglk/issues/30 @@ -34,7 +38,7 @@ export class TextInput { autocapitalize: 'off', class: 'Input', data: { - window, + window: win, }, on: { blur: () => this.onblur(), @@ -46,7 +50,7 @@ export class TextInput { rows: 1, }) .prop('disabled', true) - .appendTo(window.frameel) + .appendTo(win.frameel) } destroy() { @@ -56,12 +60,9 @@ export class TextInput { private onblur() { // If this input lost focus and no other input gained focus, then tell the metrics to resize the gameport // This is to support iOS better, which delays its `visualViewport:resize` event significantly (~700ms) - const input_is_active = document.activeElement?.tagName === 'INPUT' - if (!input_is_active) { - this.window.manager.glkote.metrics_calculator.set_gameport_height(window.innerHeight) + if (is_iOS && !is_input_focused()) { + this.set_gameport_height(true) } - - scroll_window() } private onfocus() { @@ -69,8 +70,10 @@ export class TextInput { if (this.window.type === 'buffer' && !is_pinch_zoomed()) { this.window.scroll_to_bottom() } - // Scroll the browser window over the next 600ms - scroll_window() + // In iOS tell the metrics to resize the gameport because its `visualViewport:resize` event is slowww + if (is_iOS) { + this.set_gameport_height(false) + } } /** The keydown and keypress inputs are unreliable in mobile browsers with virtual keyboards. This handler can handle character input for printable characters, but not function/arrow keys */ @@ -175,12 +178,18 @@ export class TextInput { /** Refocus the input, if it wouldn't obscure part of the update */ // On Android this forces the window to be scrolled down to the bottom, so only refocus if the virtual keyboard doesn't make the window too small for the full update text to be seen refocus() { + if (this.refocused || document.activeElement === this.el[0]) { + return + } + this.refocused = true if (this.window.type === 'buffer') { const updateheight = this.window.innerel.outerHeight()! - this.window.updatescrolltop if (updateheight > this.window.height_above_keyboard) { // If there's not enough space, then tell the metrics to resize the gameport // This is to support iOS better, which delays its `visualViewport:resize` event significantly (~700ms) - this.window.manager.glkote.metrics_calculator.set_gameport_height(window.innerHeight) + if (is_iOS) { + this.set_gameport_height(true) + } return } } @@ -189,6 +198,7 @@ export class TextInput { reset() { this.history_index = 0 + this.refocused = false this.el .attr({ 'aria-hidden': 'true', @@ -208,6 +218,10 @@ export class TextInput { } } + private set_gameport_height = debounce((full_screen: boolean) => { + this.window.manager.glkote.metrics_calculator.set_gameport_height(full_screen ? window.innerHeight : 0) + }, 50) + private submit_char(val: string) { this.window.send_text_event({ type: 'char', @@ -284,20 +298,4 @@ export class TextInput { } } } -} - -/* A little helper function to repeatedly scroll the window, because iOS sometimes scrolls badly - On iOS, when focusing the soft keyboard, the keyboard animates in over 500ms - This would normally cover up the focused input, so iOS cleverly tries to - scroll the top-level window down to bring the input into the view - But we know better: we want to scroll the input's window frame to the bottom, - without scrolling the top-level window at all. */ -const scroll_window = throttle(() => { - function do_scroll(count: number) { - window.scrollTo(0, 0) - if (count > 0) { - setTimeout(do_scroll, 50, count - 1) - } - } - do_scroll(12) -}, 1000) \ No newline at end of file +} \ No newline at end of file diff --git a/src/glkote/web/metrics.ts b/src/glkote/web/metrics.ts index 8e55583..49324a0 100644 --- a/src/glkote/web/metrics.ts +++ b/src/glkote/web/metrics.ts @@ -14,7 +14,7 @@ import {throttle} from 'lodash-es' import {is_pinch_zoomed} from '../../common/misc.js' import * as protocol from '../../common/protocol.js' -import {create} from './shared.js' +import {create, is_input_focused, is_iOS} from './shared.js' import WebGlkOte from './web.js' function get_size(el: JQuery): {height: number, width: number} { @@ -35,10 +35,12 @@ function metrics_differ(newmetrics: protocol.NormalisedMetrics, oldmetrics: prot } export default class Metrics { - // Shares the current_metrics and DOM of WebGlkOte - private metrics: protocol.NormalisedMetrics + /** When we don't know how high the screen is, use a height we've saved before, or, at the very beginning, a rough estimate */ + private height_with_keyboard = (visualViewport?.height || window.innerHeight) / 2 private loaded: Promise private glkote: WebGlkOte + // Shares the current_metrics and DOM of WebGlkOte + private metrics: protocol.NormalisedMetrics private observer?: ResizeObserver constructor(glkote: WebGlkOte) { @@ -64,6 +66,11 @@ export default class Metrics { else { $(window).on('resize', this.on_gameport_resize) } + + // iOS sends repeated visualViewport:resize events, so throttle it + if (is_iOS) { + this.on_visualViewport_resize = throttle(this.on_visualViewport_resize, 700) + } if (visualViewport) { $(visualViewport).on('resize', this.on_visualViewport_resize) } @@ -179,9 +186,15 @@ export default class Metrics { }, 200, {leading: false}) on_visualViewport_resize = () => { + // If the keyboard is active, then store the height for later + const height = visualViewport!.height + if (is_input_focused()) { + this.height_with_keyboard = height + } + // The iOS virtual keyboard does not change the gameport height, but it does change the viewport // Try to account for this by setting the gameport to the viewport height - this.set_gameport_height(visualViewport!.height) + this.set_gameport_height(height) } /** Update the gameport height and then send new metrics */ @@ -191,6 +204,10 @@ export default class Metrics { return } + if (!height) { + height = this.height_with_keyboard + } + // We set the outer height to account for any padding or margin this.glkote.dom.gameport().outerHeight(height, true) diff --git a/src/glkote/web/shared.ts b/src/glkote/web/shared.ts index a2f9f51..1ed985c 100644 --- a/src/glkote/web/shared.ts +++ b/src/glkote/web/shared.ts @@ -71,4 +71,14 @@ export class DOM { } } -export type EventFunc = (event: Partial) => void \ No newline at end of file +export type EventFunc = (event: Partial) => void + +/** Is any input element focused? */ +export function is_input_focused() { + const activeElement_tagName = document.activeElement?.tagName + return activeElement_tagName === 'INPUT' || activeElement_tagName === 'TEXTAREA' +} + +/** Try to detect iOS */ +// From https://stackoverflow.com/a/58065241/2854284 +export const is_iOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) \ No newline at end of file diff --git a/src/glkote/web/web.ts b/src/glkote/web/web.ts index d0aee1d..c3d2c3a 100644 --- a/src/glkote/web/web.ts +++ b/src/glkote/web/web.ts @@ -9,13 +9,11 @@ https://github.com/curiousdannii/asyncglk */ -import {throttle} from 'lodash-es' - import * as GlkOte from '../common/glkote.js' import * as protocol from '../../common/protocol.js' import Metrics from './metrics.js' -import {DOM} from './shared.js' +import {DOM, is_iOS} from './shared.js' import TranscriptRecorder from './transcript-recorder.js' import Windows, {GraphicsWindow} from './windows.js' @@ -101,8 +99,6 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt } windowport.empty() - $(document).on('scroll', this.on_document_scroll) - // Augment the viewport meta tag // Rather than requiring all users to update their HTML we will add new properties here // The properties we want are initial-scale, minimum-scale, width, and the new interactive-widget @@ -112,7 +108,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt // Prevent iOS from zooming in when focusing input, but allow Android to still pinch zoom // As they handle the maximum-scale viewport meta option differently, we will conditionally add it only in iOS // Idea from https://stackoverflow.com/a/62750441/2854284 - if (/iPhone OS/i.test(navigator.userAgent)) { + if (is_iOS) { viewport_meta_tag_content += ',maximum-scale=1' } @@ -208,6 +204,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt 'aria-label': 'Close', click: () => { errorpane.hide() + return false }, id: 'errorclose', text: '✖', @@ -299,12 +296,6 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt } } - // iOS devices can scroll the window even though body/#gameport are set to height 100% - // Scroll back to the top if they try - on_document_scroll = throttle(async () => { - window.scrollTo(0, 0) - }, 500, {leading: false}) - save_allstate(): AutosaveState { const graphics_bg: Array<[number, string]> = [] for (const win of this.windows.values()) { diff --git a/src/glkote/web/windows.ts b/src/glkote/web/windows.ts index a3f243c..0001a83 100644 --- a/src/glkote/web/windows.ts +++ b/src/glkote/web/windows.ts @@ -9,6 +9,7 @@ https://github.com/curiousdannii/asyncglk */ +import {disableBodyScroll, enableBodyScroll} from 'body-scroll-lock' import {debounce} from 'lodash-es' import {Blorb} from '../../blorb/blorb.js' @@ -17,7 +18,7 @@ import {is_pinch_zoomed} from '../../common/misc.js' import * as protocol from '../../common/protocol.js' import {TextInput} from './input.js' -import {create, DOM, type EventFunc} from './shared.js' +import {create, DOM, type EventFunc, is_iOS} from './shared.js' import WebGlkOte from './web.js' export type Window = BufferWindow | GraphicsWindow | GridWindow @@ -338,12 +339,25 @@ export class BufferWindow extends TextualWindow { tabindex: -1, }) .on('scroll', this.onscroll) + + if (is_iOS) { + disableBodyScroll(this.frameel[0]) + } + this.innerel = create('div', 'BufferWindowInner') .append(this.textinput.el) .appendTo(this.frameel) this.height_above_keyboard = this.frameel.height()! } + destroy(remove_frame: boolean) { + if (is_iOS) { + enableBodyScroll(this.frameel[0]) + } + + super.destroy(remove_frame) + } + /** Measure the height of the window that is currently visible (excluding virtual keyboards for example) */ measure_height() { this.height_above_keyboard = this.frameel.height()!