@bitblit/asyncglk
Version:
A Typescript Glk library
334 lines (330 loc) • 12 kB
JavaScript
/*
Web GlkOte implementation
=========================
Copyright (c) 2023 Dannii Willis
MIT licenced
https://github.com/curiousdannii/asyncglk
*/
import * as GlkOte from '../common/glkote.js';
import * as protocol from '../../common/protocol.js';
import Metrics from './metrics.js';
import { DOM, is_iOS } from './shared.js';
import TranscriptRecorder from './transcript-recorder.js';
import Windows, { GraphicsWindow } from './windows.js';
/** A GlkOte implementation for the web
*
* WebGlkOte requires the following HTML divs to be present (though the id names can be varied through the options):
*
* ```
* <div id="gameport">
* <div id="windowport"></div>
* <div id="loadingpane">Loading...</div>
* <div id="errorpane" style="display:none;"><div id="errorcontent"></div></div>
* </div>
* ```
*/
export default class WebGlkOte extends GlkOte.GlkOteBase {
dom = new DOM({
context_element: undefined,
errorcontent_id: 'errorcontent',
errorpane_id: 'errorpane',
gameport_id: 'gameport',
loadingpane_id: 'loadingpane',
prefix: '',
windowport_id: 'windowport',
});
metrics_calculator;
showing_error = false;
showing_loading = true;
transcript_recorder;
windows;
constructor() {
super();
this.metrics_calculator = new Metrics(this);
this.windows = new Windows(this);
}
async init(options) {
try {
if (!options) {
throw new Error('no options provided');
}
if (typeof jQuery === 'undefined') {
throw new Error('jQuery is not loaded');
}
// Set DOM IDs
if (options.dom_prefix) {
this.dom.prefix = options.dom_prefix;
}
if (options.errorcontent) {
this.dom.errorcontent_id = options.errorcontent;
}
if (options.errorpane) {
this.dom.errorpane_id = options.errorpane;
}
if (options.gameport) {
this.dom.gameport_id = options.gameport;
}
if (options.loadingpane) {
this.dom.loadingpane_id = options.loadingpane;
}
if (options.windowport) {
this.dom.windowport_id = options.windowport;
}
if (options.Blorb) {
this.windows.blorb = options.Blorb;
}
// The windowport is required to already exist
const windowport = this.dom.windowport();
if (!windowport.length) {
throw new Error(`Cannot find windowport element #${this.dom.windowport_id}`);
}
windowport.empty();
// 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
// See https://developer.chrome.com/blog/viewport-resize-behavior
let viewport_meta_tag_content = 'initial-scale=1,interactive-widget=resizes-content,minimum-scale=1,width=device-width';
// 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 (is_iOS) {
viewport_meta_tag_content += ',maximum-scale=1';
}
document.head.querySelector('meta[name="viewport"]').content = viewport_meta_tag_content;
await this.metrics_calculator.measure();
// Set up transcript recording
if (options.recording_url || options.recording_handler) {
if (options.recording_format && options.recording_format !== 'simple') {
console.warn('GlkOte: only the "simple" recording_format is supported');
}
else {
// Check if the user has opted out
const query_feedback = (new URLSearchParams(document.location.search)).get('feedback');
const cookie_name = options.recording_cookie || 'transcript_recording_opt_out';
if ((query_feedback && query_feedback !== '1') || document.cookie.includes(`${cookie_name}=1`)) {
console.log('User has opted out of transcript recording.');
}
else {
this.transcript_recorder = new TranscriptRecorder(options, this.windows);
}
}
}
// Note that this must be called last as it will result in VM.start() being called
return super.init(options);
}
catch (err) {
this.error(err);
}
}
autorestore(data) {
// erkyrath/glkote history is an object rather than an array
if (Array.isArray(data.history)) {
this.windows.history = data.history;
}
for (const win of this.windows.values()) {
// Scroll all buffer windows
if (win.type === 'buffer') {
win.scroll_to_bottom();
}
}
if (data.graphics_bg) {
for (const [winid, colour] of data.graphics_bg) {
this.windows.get(winid).fillcolour = colour;
}
}
if (data.transcript_recorder_session && this.transcript_recorder) {
this.transcript_recorder.session = data.transcript_recorder_session;
console.log(`Resuming autosaved transcript recording session: ${data.transcript_recorder_session}`);
}
// Always send a rearrange event after autorestoring
setTimeout(() => this.send_event({ type: 'arrange' }), 0);
}
cancel_inputs(windows) {
this.windows.cancel_inputs(windows);
}
capabilities() {
return [
'garglktext',
'graphics',
'graphicswin',
'hyperlinks',
'timer',
];
}
disable(disable) {
for (const win of this.windows.values()) {
win.textinput.el.prop('disabled', disable || !win.inputs?.type);
}
this.disabled = disable;
}
embellish_error() {
if (typeof $ === 'undefined') {
return;
}
const errorpane = $(`#${this.dom.errorpane_id}`);
// Add close button
if (!errorpane.find('#errorclose').length) {
$('<button>', {
'aria-label': 'Close',
click: () => {
errorpane.hide();
return false;
},
id: 'errorclose',
text: '✖',
type: 'button',
})
.appendTo(errorpane);
}
// If autorestoring, add a link to restart after clearing the autosave
// TODO: fix for AsyncDialog
if (this.autorestoring && !this.Dialog?.async && this.Dialog?.autosave_clear) {
const link = $('<a>', {
click: async () => {
if (!this.Dialog.async) {
await this.Dialog.autosave_clear();
}
location.reload();
},
text: 'Clear autosave and restart',
});
$('<div>')
.append(link)
.appendTo(errorpane);
}
}
error(error) {
error ??= '???';
let msg;
if (typeof error === 'string') {
msg = error;
error = new Error(error);
}
else {
msg = error.toString();
}
// Don't use jQuery, because this is called if jQuery isn't present
const errorcontent = document.getElementById(this.dom.errorcontent_id);
if (!errorcontent) {
throw new Error(msg);
}
errorcontent.innerHTML = msg;
const errorpane = document.getElementById(this.dom.errorpane_id);
if (errorpane.className === 'WarningPane') {
errorpane.className = '';
}
this.embellish_error();
errorpane.style.display = '';
this.showing_error = true;
this.hide_loading();
console.error(error);
}
exit() {
// Try to clean up and remove any event handlers
this.metrics_calculator.destroy();
this.windows.destroy();
}
getdomcontext() {
return this.dom.context_element;
}
getdomid(name) {
switch (name) {
case 'errorcontent': return this.dom.errorcontent_id;
case 'errorpane': return this.dom.errorpane_id;
case 'gameport': return this.dom.gameport_id;
case 'loadingpane': return this.dom.loadingpane_id;
case 'windowport': return this.dom.windowport_id;
default: return name;
}
}
hide_loading() {
if (!this.showing_loading) {
return;
}
this.showing_loading = false;
// Don't use jQuery because this could be called from error()
const loadingpane = document.getElementById(this.dom.loadingpane_id);
if (loadingpane) {
loadingpane.style.display = 'none';
}
}
save_allstate() {
const graphics_bg = [];
for (const win of this.windows.values()) {
if (win.type === 'graphics') {
graphics_bg.push([win.id, win.fillcolour]);
}
}
return {
graphics_bg,
history: this.windows.history,
transcript_recorder_session: this.transcript_recorder?.session,
};
}
// Send partial line input values
send_event(ev) {
if (ev.type !== 'init' && ev.type !== 'refresh' && ev.type !== 'specialresponse') {
for (const win of this.windows.values()) {
if (win.inputs?.type) {
const val = win.textinput.el.val();
if (val) {
if (!ev.partial) {
ev.partial = {};
}
ev.partial[win.id] = val;
}
}
}
}
if (this.transcript_recorder?.enabled) {
this.transcript_recorder.record_event(ev);
}
super.send_event(ev);
}
setdomcontext(val) {
this.dom.context_element = val;
}
set_page_bg(colour) {
$('body').css('background-color', colour);
}
update(data) {
if (this.showing_loading) {
this.hide_loading();
}
super.update(data);
if (this.transcript_recorder?.enabled && data.type === 'update' && !data.autorestore) {
this.transcript_recorder.record_update(data);
}
}
update_content(content) {
this.windows.update_content(content);
}
update_inputs(windows) {
this.windows.update_inputs(windows);
}
update_windows(windows) {
this.windows.update(windows);
}
warning(msg) {
// Don't show warnings if an error is already being shown
if (this.showing_error) {
return;
}
const errorpane = $(`#${this.dom.errorpane_id}`);
// Hide a warning
if (!msg) {
errorpane.hide();
return;
}
const errorcontent = document.getElementById(this.dom.errorcontent_id);
if (!errorcontent) {
console.warn(msg);
return;
}
errorcontent.innerHTML = msg;
this.embellish_error();
errorpane.addClass('WarningPane');
errorpane.show();
this.hide_loading();
}
}