Skip to content

Commit 08c2840

Browse files
committed
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.
1 parent dcb9d7f commit 08c2840

File tree

6 files changed

+80
-48
lines changed

6 files changed

+80
-48
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
"type": "module",
1515
"dependencies": {
1616
"base32768": "^3.0.1",
17+
"body-scroll-lock": "^4.0.0-beta.0",
1718
"file-saver": "^2.0.5",
1819
"lodash-es": "^4.17.21",
1920
"mute-stream": "2.0.0",
2021
"path-browserify-esm": "^1.0.6"
2122
},
2223
"devDependencies": {
24+
"@types/body-scroll-lock": "^3.1.2",
2325
"@types/file-saver": "^2.0.7",
2426
"@types/jquery": "^3.5.31",
2527
"@types/lodash-es": "^4.17.12",

src/glkote/web/input.ts

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ MIT licenced
88
https://github.com/curiousdannii/asyncglk
99
1010
*/
11-
import {throttle} from 'lodash-es'
11+
12+
import {debounce} from 'lodash-es'
1213

1314
import {KEY_CODE_DOWN, KEY_CODE_RETURN, KEY_CODE_UP, KEY_CODES_TO_NAMES, OFFSCREEN_OFFSET} from '../../common/constants.js'
1415
import {is_pinch_zoomed} from '../../common/misc.js'
1516
import * as protocol from '../../common/protocol.js'
1617

18+
import {is_input_focused, is_iOS} from './shared.js'
1719
import {apply_text_run_styles, type Window} from './windows.js'
1820

1921
const MAX_HISTORY_LENGTH = 25
@@ -22,10 +24,12 @@ export class TextInput {
2224
el: JQuery<HTMLElement>
2325
history_index = 0
2426
is_line = false
27+
/** Whether this input has been refocused since it was last reset */
28+
refocused = false
2529
window: Window
2630

27-
constructor(window: Window) {
28-
this.window = window
31+
constructor(win: Window) {
32+
this.window = win
2933

3034
// We use a textarea rather than an input because mobile Chrome shows an extra bar which can't be removed
3135
// See https://github.com/curiousdannii/asyncglk/issues/30
@@ -34,7 +38,7 @@ export class TextInput {
3438
autocapitalize: 'off',
3539
class: 'Input',
3640
data: {
37-
window,
41+
window: win,
3842
},
3943
on: {
4044
blur: () => this.onblur(),
@@ -46,7 +50,7 @@ export class TextInput {
4650
rows: 1,
4751
})
4852
.prop('disabled', true)
49-
.appendTo(window.frameel)
53+
.appendTo(win.frameel)
5054
}
5155

5256
destroy() {
@@ -56,21 +60,20 @@ export class TextInput {
5660
private onblur() {
5761
// If this input lost focus and no other input gained focus, then tell the metrics to resize the gameport
5862
// This is to support iOS better, which delays its `visualViewport:resize` event significantly (~700ms)
59-
const input_is_active = document.activeElement?.tagName === 'INPUT'
60-
if (!input_is_active) {
61-
this.window.manager.glkote.metrics_calculator.set_gameport_height(window.innerHeight)
63+
if (is_iOS && !is_input_focused()) {
64+
this.set_gameport_height(true)
6265
}
63-
64-
scroll_window()
6566
}
6667

6768
private onfocus() {
6869
// Ensure a buffer window is scrolled down
6970
if (this.window.type === 'buffer' && !is_pinch_zoomed()) {
7071
this.window.scroll_to_bottom()
7172
}
72-
// Scroll the browser window over the next 600ms
73-
scroll_window()
73+
// In iOS tell the metrics to resize the gameport because its `visualViewport:resize` event is slowww
74+
if (is_iOS) {
75+
this.set_gameport_height(false)
76+
}
7477
}
7578

7679
/** 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 {
175178
/** Refocus the input, if it wouldn't obscure part of the update */
176179
// 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
177180
refocus() {
181+
if (this.refocused || document.activeElement === this.el[0]) {
182+
return
183+
}
184+
this.refocused = true
178185
if (this.window.type === 'buffer') {
179186
const updateheight = this.window.innerel.outerHeight()! - this.window.updatescrolltop
180187
if (updateheight > this.window.height_above_keyboard) {
181188
// If there's not enough space, then tell the metrics to resize the gameport
182189
// This is to support iOS better, which delays its `visualViewport:resize` event significantly (~700ms)
183-
this.window.manager.glkote.metrics_calculator.set_gameport_height(window.innerHeight)
190+
if (is_iOS) {
191+
this.set_gameport_height(true)
192+
}
184193
return
185194
}
186195
}
@@ -189,6 +198,7 @@ export class TextInput {
189198

190199
reset() {
191200
this.history_index = 0
201+
this.refocused = false
192202
this.el
193203
.attr({
194204
'aria-hidden': 'true',
@@ -208,6 +218,10 @@ export class TextInput {
208218
}
209219
}
210220

221+
private set_gameport_height = debounce((full_screen: boolean) => {
222+
this.window.manager.glkote.metrics_calculator.set_gameport_height(full_screen ? window.innerHeight : 0)
223+
}, 50)
224+
211225
private submit_char(val: string) {
212226
this.window.send_text_event({
213227
type: 'char',
@@ -284,20 +298,4 @@ export class TextInput {
284298
}
285299
}
286300
}
287-
}
288-
289-
/* A little helper function to repeatedly scroll the window, because iOS sometimes scrolls badly
290-
On iOS, when focusing the soft keyboard, the keyboard animates in over 500ms
291-
This would normally cover up the focused input, so iOS cleverly tries to
292-
scroll the top-level window down to bring the input into the view
293-
But we know better: we want to scroll the input's window frame to the bottom,
294-
without scrolling the top-level window at all. */
295-
const scroll_window = throttle(() => {
296-
function do_scroll(count: number) {
297-
window.scrollTo(0, 0)
298-
if (count > 0) {
299-
setTimeout(do_scroll, 50, count - 1)
300-
}
301-
}
302-
do_scroll(12)
303-
}, 1000)
301+
}

src/glkote/web/metrics.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {throttle} from 'lodash-es'
1414
import {is_pinch_zoomed} from '../../common/misc.js'
1515
import * as protocol from '../../common/protocol.js'
1616

17-
import {create} from './shared.js'
17+
import {create, is_input_focused, is_iOS} from './shared.js'
1818
import WebGlkOte from './web.js'
1919

2020
function get_size(el: JQuery<HTMLElement>): {height: number, width: number} {
@@ -35,10 +35,12 @@ function metrics_differ(newmetrics: protocol.NormalisedMetrics, oldmetrics: prot
3535
}
3636

3737
export default class Metrics {
38-
// Shares the current_metrics and DOM of WebGlkOte
39-
private metrics: protocol.NormalisedMetrics
38+
/** 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 */
39+
private height_with_keyboard = (visualViewport?.height || window.innerHeight) / 2
4040
private loaded: Promise<void>
4141
private glkote: WebGlkOte
42+
// Shares the current_metrics and DOM of WebGlkOte
43+
private metrics: protocol.NormalisedMetrics
4244
private observer?: ResizeObserver
4345

4446
constructor(glkote: WebGlkOte) {
@@ -64,6 +66,11 @@ export default class Metrics {
6466
else {
6567
$(window).on('resize', this.on_gameport_resize)
6668
}
69+
70+
// iOS sends repeated visualViewport:resize events, so throttle it
71+
if (is_iOS) {
72+
this.on_visualViewport_resize = throttle(this.on_visualViewport_resize, 700)
73+
}
6774
if (visualViewport) {
6875
$(visualViewport).on('resize', this.on_visualViewport_resize)
6976
}
@@ -179,9 +186,15 @@ export default class Metrics {
179186
}, 200, {leading: false})
180187

181188
on_visualViewport_resize = () => {
189+
// If the keyboard is active, then store the height for later
190+
const height = visualViewport!.height
191+
if (is_input_focused()) {
192+
this.height_with_keyboard = height
193+
}
194+
182195
// The iOS virtual keyboard does not change the gameport height, but it does change the viewport
183196
// Try to account for this by setting the gameport to the viewport height
184-
this.set_gameport_height(visualViewport!.height)
197+
this.set_gameport_height(height)
185198
}
186199

187200
/** Update the gameport height and then send new metrics */
@@ -191,6 +204,10 @@ export default class Metrics {
191204
return
192205
}
193206

207+
if (!height) {
208+
height = this.height_with_keyboard
209+
}
210+
194211
// We set the outer height to account for any padding or margin
195212
this.glkote.dom.gameport().outerHeight(height, true)
196213

src/glkote/web/shared.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,14 @@ export class DOM {
7171
}
7272
}
7373

74-
export type EventFunc = (event: Partial<protocol.Event>) => void
74+
export type EventFunc = (event: Partial<protocol.Event>) => void
75+
76+
/** Is any input element focused? */
77+
export function is_input_focused() {
78+
const activeElement_tagName = document.activeElement?.tagName
79+
return activeElement_tagName === 'INPUT' || activeElement_tagName === 'TEXTAREA'
80+
}
81+
82+
/** Try to detect iOS */
83+
// From https://stackoverflow.com/a/58065241/2854284
84+
export const is_iOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)

src/glkote/web/web.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,11 @@ https://github.com/curiousdannii/asyncglk
99
1010
*/
1111

12-
import {throttle} from 'lodash-es'
13-
1412
import * as GlkOte from '../common/glkote.js'
1513
import * as protocol from '../../common/protocol.js'
1614

1715
import Metrics from './metrics.js'
18-
import {DOM} from './shared.js'
16+
import {DOM, is_iOS} from './shared.js'
1917
import TranscriptRecorder from './transcript-recorder.js'
2018
import Windows, {GraphicsWindow} from './windows.js'
2119

@@ -101,8 +99,6 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
10199
}
102100
windowport.empty()
103101

104-
$(document).on('scroll', this.on_document_scroll)
105-
106102
// Augment the viewport meta tag
107103
// Rather than requiring all users to update their HTML we will add new properties here
108104
// 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
112108
// Prevent iOS from zooming in when focusing input, but allow Android to still pinch zoom
113109
// As they handle the maximum-scale viewport meta option differently, we will conditionally add it only in iOS
114110
// Idea from https://stackoverflow.com/a/62750441/2854284
115-
if (/iPhone OS/i.test(navigator.userAgent)) {
111+
if (is_iOS) {
116112
viewport_meta_tag_content += ',maximum-scale=1'
117113
}
118114

@@ -208,6 +204,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
208204
'aria-label': 'Close',
209205
click: () => {
210206
errorpane.hide()
207+
return false
211208
},
212209
id: 'errorclose',
213210
text: '✖',
@@ -299,12 +296,6 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
299296
}
300297
}
301298

302-
// iOS devices can scroll the window even though body/#gameport are set to height 100%
303-
// Scroll back to the top if they try
304-
on_document_scroll = throttle(async () => {
305-
window.scrollTo(0, 0)
306-
}, 500, {leading: false})
307-
308299
save_allstate(): AutosaveState {
309300
const graphics_bg: Array<[number, string]> = []
310301
for (const win of this.windows.values()) {

src/glkote/web/windows.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ https://github.com/curiousdannii/asyncglk
99
1010
*/
1111

12+
import {disableBodyScroll, enableBodyScroll} from 'body-scroll-lock'
1213
import {debounce} from 'lodash-es'
1314

1415
import {Blorb} from '../../blorb/blorb.js'
@@ -17,7 +18,7 @@ import {is_pinch_zoomed} from '../../common/misc.js'
1718
import * as protocol from '../../common/protocol.js'
1819

1920
import {TextInput} from './input.js'
20-
import {create, DOM, type EventFunc} from './shared.js'
21+
import {create, DOM, type EventFunc, is_iOS} from './shared.js'
2122
import WebGlkOte from './web.js'
2223

2324
export type Window = BufferWindow | GraphicsWindow | GridWindow
@@ -338,12 +339,25 @@ export class BufferWindow extends TextualWindow {
338339
tabindex: -1,
339340
})
340341
.on('scroll', this.onscroll)
342+
343+
if (is_iOS) {
344+
disableBodyScroll(this.frameel[0])
345+
}
346+
341347
this.innerel = create('div', 'BufferWindowInner')
342348
.append(this.textinput.el)
343349
.appendTo(this.frameel)
344350
this.height_above_keyboard = this.frameel.height()!
345351
}
346352

353+
destroy(remove_frame: boolean) {
354+
if (is_iOS) {
355+
enableBodyScroll(this.frameel[0])
356+
}
357+
358+
super.destroy(remove_frame)
359+
}
360+
347361
/** Measure the height of the window that is currently visible (excluding virtual keyboards for example) */
348362
measure_height() {
349363
this.height_above_keyboard = this.frameel.height()!

0 commit comments

Comments
 (0)