@bitblit/asyncglk
Version:
A Typescript Glk library
188 lines (184 loc) • 8.54 kB
JavaScript
/*
Metrics and resize handlers
===========================
Copyright (c) 2023 Dannii Willis
MIT licenced
https://github.com/curiousdannii/asyncglk
*/
import { throttle } from 'lodash-es';
import { is_pinch_zoomed } from '../../common/misc.js';
import * as protocol from '../../common/protocol.js';
import { create, is_input_focused, is_iOS } from './shared.js';
import WebGlkOte from './web.js';
function get_size(el) {
return {
height: el.outerHeight(),
width: el.outerWidth(),
};
}
/** Compare two metrics to see if they differ enough to send an arrange event */
function metrics_differ(newmetrics, oldmetrics) {
return (oldmetrics.buffercharheight !== newmetrics.buffercharheight ||
oldmetrics.buffercharwidth !== newmetrics.buffercharwidth ||
oldmetrics.gridcharheight !== newmetrics.gridcharheight ||
oldmetrics.gridcharwidth !== newmetrics.gridcharwidth ||
oldmetrics.height !== newmetrics.height ||
oldmetrics.width !== newmetrics.width);
}
export default class Metrics {
/** 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 */
height_with_keyboard = (visualViewport?.height || window.innerHeight) / 2;
loaded;
glkote;
// Shares the current_metrics and DOM of WebGlkOte
metrics;
observer;
constructor(glkote) {
this.glkote = glkote;
this.metrics = glkote.current_metrics;
// AsyncGlk may have started after a DOMContentLoaded event, but Metrics needs the page to be fully loaded so that the CSS and fonts are in use
// If the page is fast and already loaded by the time we get here (or, apparently, always for file: pages) then just use an auto-resolved promise
if (document.readyState === 'complete') {
this.loaded = Promise.resolve();
}
else {
this.loaded = new Promise((resolve) => {
window.addEventListener('load', resolve, { once: true });
});
}
// Use a resize observer on #gameport, or else a resize handler on window
if (window.ResizeObserver) {
this.observer = new ResizeObserver(this.on_gameport_resize);
this.observer.observe(this.glkote.dom.gameport()[0]);
}
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);
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
else {
$(window).off('resize', this.on_gameport_resize);
}
if (visualViewport) {
$(visualViewport).off('resize', this.on_visualViewport_resize);
}
}
async measure() {
// Ensure #gameport exists
const dom = this.glkote.dom;
const gameport = dom.gameport();
if (!gameport.length) {
throw new Error(`Cannot find gameport element #${dom.gameport_id}`);
}
// Old versions of GlkOte used a pre-existing #layouttestpane, remove it if it exists
dom.id('layouttestpane').remove();
// Create a layout test pane
const layout_test_pane = dom.create('div', 'layout_test_pane');
layout_test_pane.text('This should not be visible');
// Create the test windows
const line = $('<div>');
create('span', 'Style_normal').text('12345678').appendTo(line);
const bufwin = create('div', 'WindowFrame BufferWindow');
const bufinnerwin = create('div', 'BufferWindowInner').appendTo(bufwin);
const bufline1 = line.clone().addClass('BufferLine').appendTo(bufinnerwin);
const bufline2 = line.clone().addClass('BufferLine').appendTo(bufinnerwin);
create('span', 'InvisibleCursor').appendTo(bufline2);
const bufspan = bufline1.children('span');
layout_test_pane.append(bufwin);
const graphwin = create('div', 'WindowFrame GraphicsWindow');
const graphcanvas = $('<canvas>', {
height: 32,
width: 64,
}).appendTo(graphwin);
layout_test_pane.append(graphwin);
const gridwin = create('div', 'WindowFrame GridWindow');
const gridline1 = line.clone().addClass('GridLine').appendTo(gridwin);
const gridline2 = line.clone().addClass('GridLine').appendTo(gridwin);
const gridspan = gridline1.children('span');
layout_test_pane.append(gridwin);
gameport.append(layout_test_pane);
// Wait first for the load event for the CSS to be loaded
await this.loaded;
// And then for the actual font(s) to be loaded
const font_family = getComputedStyle(gridwin[0]).getPropertyValue('--glkote-grid-mono-family').split(',')[0].replace(/"/g, '');
await document.fonts.load(`14px ${font_family}`);
// Measure the gameport height/width, excluding border and padding
this.metrics.height = gameport.height();
this.metrics.width = gameport.width();
// Measure the buffer window
const bufwinsize = get_size(bufwin);
const bufspansize = get_size(bufspan);
const bufline1size = get_size(bufline1);
const bufline2size = get_size(bufline2);
// A minimum of 1, but not necessarily an integer
this.metrics.buffercharheight = Math.max(1, bufline2.position().top - bufline1.position().top);
this.metrics.buffercharwidth = Math.max(1, bufspan.width() / 8);
this.metrics.buffermarginx = bufwinsize.width - bufspansize.width;
this.metrics.buffermarginy = bufwinsize.height - (bufline1size.height + bufline2size.height);
// Measure the graphics window
const graphicswinsize = get_size(graphwin);
const canvassize = get_size(graphcanvas);
this.metrics.graphicsmarginx = graphicswinsize.width - canvassize.width;
this.metrics.graphicsmarginy = graphicswinsize.height - canvassize.height;
// Measure the grid window
const gridwinsize = get_size(gridwin);
const gridspansize = get_size(gridspan);
const gridline1size = get_size(gridline1);
const gridline2size = get_size(gridline2);
// A minimum of 1, but not necessarily an integer
this.metrics.gridcharheight = Math.max(1, gridline2.position().top - gridline1.position().top);
this.metrics.gridcharwidth = Math.max(1, gridspan.width() / 8);
this.metrics.gridmarginx = gridwinsize.width - gridspansize.width;
this.metrics.gridmarginy = gridwinsize.height - (gridline1size.height + gridline2size.height);
// Clean up
layout_test_pane.remove();
}
on_gameport_resize = throttle(async () => {
// Delay again if not yet inited or disabled
if (this.glkote.disabled || !this.glkote.inited()) {
this.on_gameport_resize();
return;
}
const oldmetrics = Object.assign({}, this.metrics);
await this.measure();
if (metrics_differ(this.metrics, oldmetrics)) {
this.glkote.send_event({ type: 'arrange' });
}
}, 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(height);
};
/** Update the gameport height and then send new metrics */
set_gameport_height(height) {
// Don't do anything if the window is pinch zoomed
if (is_pinch_zoomed()) {
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);
// Safari might have scrolled weirdly, so try to put it right
window.scrollTo(0, 0);
// Measure and send the new metrics
this.on_gameport_resize();
}
}