@bitblit/asyncglk
Version:
A Typescript Glk library
873 lines (869 loc) • 33.6 kB
JavaScript
/*
GlkOte windows
==============
Copyright (c) 2024 Dannii Willis
MIT licenced
https://github.com/curiousdannii/asyncglk
*/
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
import { debounce } from 'lodash-es';
import { Blorb } from '../../blorb/blorb.js';
import { NBSP } from '../../common/constants.js';
import { is_pinch_zoomed } from '../../common/misc.js';
import * as protocol from '../../common/protocol.js';
import { TextInput } from './input.js';
import { create, DOM, is_iOS } from './shared.js';
import WebGlkOte from './web.js';
function no_text_selected() {
return (window.getSelection() + '') === '';
}
class WindowBase {
blorb;
desired = true;
dom;
frameel;
id;
inputs;
manager;
metrics;
textinput;
type;
constructor(options) {
this.blorb = options.blorb;
this.dom = options.dom;
this.id = options.id;
this.manager = options.manager;
this.metrics = options.metrics;
this.type = options.type;
this.frameel = this.dom.create('div', `window${options.id}`, {
class: `WindowFrame ${window_types[this.type]} WindowRock_${options.rock}`,
click: (ev) => this.onclick(ev),
})
.appendTo(this.dom.windowport());
// (this as any as Window) is a silly hack to work around Typescript's abstract class rules
this.textinput = new TextInput(this);
}
destroy(remove_frame) {
this.textinput.destroy();
if (remove_frame) {
this.frameel.remove();
}
else {
this.inputs = undefined;
}
}
// Dummy function which is only needed for buffer windows
measure_height() { }
onclick(ev) {
if (this.inputs?.type && no_text_selected()) {
this.textinput.el.trigger('focus');
return false;
}
}
send_text_event(ev) {
// Measure the height of the window, which should account for a virtual keyboard
this.measure_height();
this.textinput.reset();
// Clear the input type so we won't accidentally send it again
this.inputs.type = undefined;
// We're the last active window
this.manager.active_window = this;
// Set the event's window prop and send it
ev.window = this.id;
this.manager.send_event(ev);
}
}
// Apply text run styles, accounting for reverse mode
export function apply_text_run_styles(orig_styles, reverse, el) {
// Make a copy of the styles, I'm not sure this is strictly necessary but better to be safe
const styles = Object.assign({}, orig_styles);
// Swap colours if reverse mode
if (reverse) {
const bg = styles['background-color'];
const fg = styles.color;
if (bg) {
styles.color = bg;
}
else {
delete styles.color;
}
if (fg) {
styles['background-color'] = fg;
}
else {
delete styles['background-color'];
}
delete styles.reverse;
}
el.css(styles);
}
// Split a string into words, with whitespace included after the words it follows
function split_after_spaces(text) {
const res = [''];
const whitespace = /\s/;
let prev_whitespace = false;
for (const c of text) {
const is_whitespace = whitespace.test(c);
if (!is_whitespace && prev_whitespace) {
res.unshift('');
}
res[0] += c;
prev_whitespace = is_whitespace;
}
res.reverse();
return res;
}
class TextualWindow extends WindowBase {
bg;
fg;
last_run_styles;
styles;
constructor(options) {
super(options);
// We need the `this` object provided by jQuery, so we can't use an arrow function handler like we normally do
const onlink = (target) => this.onlink(target);
this.frameel.on('click', 'a', function () { onlink(this); });
// Add stylehints
if (options.styles) {
this.styles = options.styles;
this.add_stylehints();
}
}
/** Format CSS rules and add to the window */
add_stylehints() {
const windowid = `#window${this.id}`;
const styles = {};
if (this.styles) {
// Copy all the styles
for (const [selector, original_styles] of Object.entries(this.styles)) {
const full_selector = `${windowid} ${selector}`.trim();
const css_styles = styles[full_selector] = Object.assign({}, original_styles);
// Create a .reverse rule for when text runs are reverse mode
if (selector) {
const bg = css_styles['background-color'];
const fg = css_styles.color;
if (bg || fg) {
const reverse_styles = styles[`${full_selector}.reverse`] = {};
if (bg) {
reverse_styles.color = bg;
}
if (fg) {
reverse_styles['background-color'] = fg;
}
}
// Monospace mode - handled in create_text_run
delete css_styles.monospace;
// In reverse mode don't set colour styles
if (css_styles.reverse) {
delete css_styles['background-color'];
delete css_styles.color;
}
delete css_styles.reverse;
// Check if we have any properties left
if (Object.keys(css_styles).length === 0) {
delete styles[full_selector];
}
}
}
// Copy styles for the <input>
// May not play nice with reverse
if (styles[`${windowid} .Style_input`]) {
styles[`${windowid} .LineInput`] = styles[`${windowid} .Style_input`];
}
}
// Set window background colour, for normal and reverse mode
const normal_styles = styles[`${windowid} .Style_normal`];
const bg = this.bg || normal_styles?.['background-color'];
const fg = this.fg || normal_styles?.color;
if (bg || fg) {
if (!styles[windowid]) {
styles[windowid] = {};
}
styles[windowid]['background-color'] = bg || `var(--glkote-${this.type}-bg)`;
styles[`${windowid}.reverse`] = {
'background-color': fg || `var(--glkote-${this.type}-reverse-bg)`,
};
}
// Finally output the styles
if (Object.keys(styles).length) {
this.frameel.children('style').remove();
this.frameel.prepend(`<style>${Object.entries(styles).map(rule => {
const [selector, properties] = rule;
return `${selector} {${Object.entries(properties).map(property => {
const [name, value] = property;
return `${name}: ${value}`;
}).join('; ')}}`;
}).join('\n')}</style>`);
}
}
create_text_run(run, split_words) {
const run_style = run.style;
// Is this run or this style monospace mode?
const monospace_val = run.css_styles?.monospace ?? this.styles?.[`.Style_${run_style}`]?.monospace;
let monospace_class = '';
// Only add the class if we're changing the default font-family for this style
if (typeof monospace_val !== 'undefined') {
if (run_style === 'preformatted') {
if (!monospace_val) {
monospace_class = ' proportional';
}
}
else if (monospace_val) {
monospace_class = ' monospace';
}
}
// Is this run or this style reverse mode?
const reverse = run.css_styles?.reverse ?? this.styles?.[`.Style_${run_style}`]?.reverse;
// Create a template span and apply classes and styles
const el = create('span', `Style_${run_style}${reverse ? ' reverse' : ''}${monospace_class}`);
if (run.css_styles) {
apply_text_run_styles(run.css_styles, !!reverse, el);
}
// Split words if instructed to, or if this text run is monospaced
if (typeof split_words === 'undefined') {
split_words = !!monospace_val || run_style === 'preformatted';
}
const els = split_words
? $(split_after_spaces(run.text).map(text => el.clone().text(text)[0]))
: el.text(run.text);
// Wrap in a link
if (run.hyperlink) {
return $('<a>', {
data: {
glklink: run.hyperlink,
},
href: '#',
})
.append(els);
}
return els;
}
onlink(target) {
const linkval = $(target).data('glklink');
if (linkval) {
if (this.inputs?.hyperlink) {
this.manager.send_event({
type: 'hyperlink',
value: linkval,
window: this.id,
});
}
return false;
}
}
/** Refresh styles after a cleared window */
refresh_styles(bg, fg) {
let styles_need_refreshing;
if (typeof bg !== 'undefined' && bg !== this.bg) {
this.bg = bg;
styles_need_refreshing = 1;
}
if (typeof fg !== 'undefined' && fg !== this.fg) {
this.fg = fg;
styles_need_refreshing = 1;
}
if (styles_need_refreshing) {
this.add_stylehints();
}
}
}
const inline_alignment_classes = {
inlinecenter: 'ImageInlineCenter',
inlinedown: 'ImageInlineDown',
inlineup: 'ImageInlineUp',
marginleft: 'ImageMarginLeft',
marginright: 'ImageMarginRight',
};
export class BufferWindow extends TextualWindow {
type = 'buffer';
/** How much height is available with the keyboard active */
height_above_keyboard;
innerel;
is_scrolled_down = true;
lastline;
updatescrolltop = 0;
constructor(options) {
super(options);
this.frameel.attr({
'aria-live': 'polite',
role: 'log',
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) {
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();
}
onclick(_) {
if (this.inputs?.type && no_text_selected()) {
// Check that we've scrolled to the bottom (if there is actually text in this window)
if (this.lastline && this.height_above_keyboard) {
const rect = this.lastline[0].getBoundingClientRect();
if (rect.bottom < 0 || rect.top > document.documentElement.clientHeight) {
return false;
}
}
this.textinput.el.trigger('focus');
return false;
}
}
// When the frame element is scrolled, update whether it's scrolled to the bottom
onscroll = () => {
const frameel = this.frameel[0];
this.is_scrolled_down = frameel.scrollHeight - frameel.scrollTop - frameel.clientHeight < 1;
};
// Scroll to the bottom, unless `after_metrics_change` is set, in which case only scroll if we *should* be at the bottom (used after updating metrics)
scroll_to_bottom(after_metrics_change) {
if (!after_metrics_change || this.is_scrolled_down) {
this.frameel.scrollTop(this.innerel.height());
this.is_scrolled_down = true;
}
}
update(data) {
if (data.clear) {
// Reset the text input before removing the window's buffer lines so that it won't be detached from the document (which would then remove its event handlers)
this.textinput.reset();
this.innerel.children('.BufferLine').remove();
this.is_scrolled_down = true;
this.lastline = undefined;
this.last_run_styles = undefined;
this.refresh_styles(data.bg, data.fg);
}
// If the text field is missing, just do nothing
if (!data.text) {
return;
}
// Tell ARIA that we're busy while we're updating the window
this.frameel.attr('aria-busy', 'true');
// Get the scrolltop for this update
this.updatescrolltop = Math.max(0, (this.lastline?.position().top || 0) - 20);
let line_index = 0;
while (line_index < data.text.length) {
const line = data.text[line_index++];
const content = line.content;
let divel;
let line_has_style;
if (line.append && this.lastline) {
divel = this.lastline;
line_has_style = 1;
}
else {
divel = create('div', 'BufferLine');
this.innerel.append(divel);
this.lastline = divel;
if (!content?.length) {
divel.addClass('BlankPara');
divel.append(create('span', 'BlankLineSpace').text(NBSP));
continue;
}
}
if (line.flowbreak) {
divel.addClass('FlowBreak');
}
if (!content?.length) {
continue;
}
for (let run_index = 0; run_index < content.length; run_index++) {
let run;
const instruction = content[run_index];
if (typeof instruction === 'string') {
run = {
style: content[run_index++],
text: content[run_index],
};
}
else if ('special' in instruction && instruction.special === 'image') {
const el = $('<img>', {
alt: instruction.alttext || `Image ${instruction.image}`,
class: inline_alignment_classes[instruction.alignment || 'inlineup'],
height: instruction.height,
src: this.blorb && this.blorb.get_image_url(instruction.image) || instruction.url,
width: instruction.width,
});
if (instruction.hyperlink) {
$('<a>', {
data: {
glklink: instruction.hyperlink,
},
href: '#',
})
.append(el)
.appendTo(divel);
continue;
}
divel.append(el);
continue;
}
else {
run = instruction;
}
if (!line_has_style) {
line_has_style = 1;
divel.addClass(`Style_${run.style}_par`);
}
divel.append(this.create_text_run(run, line_index === data.text.length ? true : undefined));
// Store the last text run for setting styles in the input
this.last_run_styles = run.css_styles;
}
}
// Scroll down
if (!is_pinch_zoomed()) {
this.frameel.scrollTop(this.updatescrolltop);
}
// TODO: Trim log?
this.frameel.attr('aria-busy', 'false');
}
}
export class GraphicsWindow extends WindowBase {
type = 'graphics';
buffer;
canvas;
fillcolour = '';
framequeue = [];
/** Height in CSS pixels */
height = 0;
image_cache = new Map();
/** Width in CSS pixels */
width = 0;
constructor(options) {
super(options);
// Create the canvas
this.canvas = this.dom.create('canvas', `win${options.id}_canvas`, {
data: {
window: this,
},
});
this.frameel.append(this.canvas);
this.manager.canvasResizeObserver?.observe(this.canvas[0]);
// And a buffer canvas to reduce flicker
this.buffer = this.dom.create('canvas', `win${options.id}_buffer`);
}
// Clear the image cache 5 seconds after the last frame, so that the cache can be useful for animations, but we don't waste memory for images which are only updated each turn
clear_cache = debounce(() => {
this.image_cache.clear();
}, 5000);
// Decode and cache an image
decode_image(key, url) {
const image = new Image();
image.src = url;
return image.decode()
.then(() => {
this.image_cache.set(key, image);
})
.catch(() => { });
}
destroy(remove_frame) {
this.manager.canvasResizeObserver?.unobserve(this.canvas[0]);
super.destroy(remove_frame);
}
onclick(ev) {
if (this.inputs?.mouse && ev.button === 0) {
this.inputs.mouse = false;
this.manager.send_event({
type: 'mouse',
window: this.id,
x: Math.floor(ev.offsetX - this.metrics.graphicsmarginx / 2),
y: Math.floor(ev.offsetY - this.metrics.graphicsmarginy / 2),
});
return false;
}
return super.onclick(ev);
}
set_dimensions(height, width) {
// First set the things which use CSS pixels
this.canvas.css({ height, width });
this.height = height;
this.width = width;
// Then the canvas dimensions using devicePixelRatio
const dPR = devicePixelRatio;
height = height * dPR;
width = width * dPR;
this.buffer.attr({ height, width });
this.canvas.attr({ height, width });
// The resize observer will handle setting true pixel sizes if supported
}
// This function is async because images must be loaded asynchrounously, and each operation painted in sequence, but the rest of GlkOte doesn't need to await it
async update() {
const buffercontext = this.buffer[0].getContext('2d');
// If we're slow loading images then more than one frame may have arrived, so go through the queue now
for (let i = 0; i < this.framequeue.length; i++) {
const frame = this.framequeue[i];
// Stop if the window is collapsed
if (!this.height || !this.width) {
break;
}
// There shouldn't be any empty frames, but check just in case
if (!frame.length) {
continue;
}
// Preload images
const loading_images = [];
for (const op of frame) {
if (op.special === 'image') {
const key = op.url || op.image;
if (this.image_cache.has(key)) {
continue;
}
const url = op.url || this.blorb && this.blorb.get_image_url(op.image);
if (url) {
loading_images.push(this.decode_image(key, url));
}
}
}
await Promise.all(loading_images);
const dPR = devicePixelRatio;
for (const op of frame) {
switch (op.special) {
case 'fill':
buffercontext.fillStyle = op.color || this.fillcolour;
if (Number.isFinite(op.x)) {
buffercontext.fillRect(op.x * dPR, op.y * dPR, op.width * dPR, op.height * dPR);
}
else {
buffercontext.fillRect(0, 0, parseInt(this.buffer.attr('width')), parseInt(this.buffer.attr('height')));
}
break;
case 'image': {
const image = this.image_cache.get(op.url || op.image);
if (image) {
buffercontext.drawImage(image, op.x * dPR, op.y * dPR, op.width * dPR, op.height * dPR);
}
break;
}
case 'setcolor':
this.fillcolour = op.color;
break;
}
}
}
// Now that all operations/frames are finished, draw the buffer to the main canvas (as long as it hasn't been collapsed)
if (this.height && this.width) {
this.canvas[0].getContext('2d').drawImage(this.buffer[0], 0, 0);
}
// Empty the queue
this.framequeue.length = 0;
// And queue the cache to be cleared, if more frames don't arrive soon
this.clear_cache();
}
}
class GridWindow extends TextualWindow {
type = 'grid';
height = 0;
lines = [];
width = 0;
constructor(options) {
super(options);
this.frameel.attr({
'aria-atomic': 'true',
'aria-live': 'off',
role: 'status',
});
}
onclick(ev) {
if (this.inputs?.mouse && ev.button === 0 && no_text_selected()) {
this.inputs.mouse = false;
this.manager.send_event({
type: 'mouse',
window: this.id,
x: Math.floor((ev.offsetX - this.metrics.gridmarginx / 2) / this.metrics.gridcharwidth),
y: Math.floor((ev.offsetY - this.metrics.gridmarginy / 2) / this.metrics.gridcharheight),
});
return false;
}
return super.onclick(ev);
}
update(data) {
if (data.clear) {
this.last_run_styles = undefined;
this.refresh_styles(data.bg, data.fg);
}
// Tell ARIA that we're busy while we're updating the window
this.frameel.attr('aria-busy', 'true');
for (const line of data.lines) {
const lineel = this.lines[line.line];
if (!lineel.length) {
throw new Error(`Got content for nonexistent line ${line.line} of window ${this.id}`);
}
const content = line.content;
if (!content || !content.length) {
lineel.text(NBSP);
}
else {
lineel.empty();
for (let i = 0; i < content.length; i++) {
let run;
if (typeof content[i] === 'string') {
run = {
style: content[i++],
text: content[i],
};
}
else {
run = content[i];
}
lineel.append(this.create_text_run(run, false));
// Store the last text run for setting styles in the input
this.last_run_styles = run.css_styles;
}
}
}
// Apply the class of 'reverse' to the window if all the text in it is reversed
this.frameel.toggleClass('reverse', $(`#window${this.id} span:not(.reverse)`).length === 0)
// If there are more than 4 lines, set aria-live to polite
.attr({
'aria-busy': 'false',
'aria-live': this.lines.length > 3 ? 'polite' : 'off',
});
}
}
const window_classes = {
buffer: BufferWindow,
graphics: GraphicsWindow,
grid: GridWindow,
};
const window_types = {
buffer: 'BufferWindow',
graphics: 'GraphicsWindow',
grid: 'GridWindow',
};
export default class Windows extends Map {
active_window;
blorb; // Note will be set after this is constructed, in WebGlkOte.init
canvasResizeObserver; // Will only be created if the browser's ResizeObserver supports devicePixelContentBoxSize
dom;
glkote;
history = [];
metrics;
send_event;
constructor(glkote) {
super();
this.dom = glkote.dom;
this.glkote = glkote;
this.metrics = glkote.current_metrics;
if (window.ResizeObserver) {
// We can use a ResizeObserver to get true pixel dimensions for canvases, but it's not supported in all browsers, so test
const testRO = new ResizeObserver((entries) => {
if (typeof entries?.[0].devicePixelContentBoxSize?.[0].blockSize !== 'undefined') {
this.canvasResizeObserver = new ResizeObserver((entries) => this.oncanvasresize(entries));
}
testRO.disconnect();
});
testRO.observe(document.body);
}
this.send_event = ev => glkote.send_event(ev);
$(document).on('keydown', this.onkeydown);
this.dom.gameport().on('click', () => this.onclick());
}
// Clean up after a GlkOte.exit() call, but don't empty the HTML
destroy() {
for (const win of this.values()) {
win.destroy(false);
this.delete(win.id);
}
$(document).off('keydown', this.onkeydown);
}
cancel_inputs(windows) {
const newinputs = {};
for (const window of windows) {
newinputs[window.id] = window;
}
for (const win of this.values()) {
const update = newinputs[win.id];
if (!update && win.inputs) {
if (win.textinput.el.is(':focus')) {
this.active_window = win;
}
win.textinput.el.prop('disabled', true);
delete win.inputs;
}
}
}
// Use a resize observer to set true pixel sizes on graphics windows
oncanvasresize(entries) {
for (const entry of entries) {
const box = entry.devicePixelContentBoxSize[0];
const height = box.blockSize;
const width = box.inlineSize;
const win = $(entry.target).data('window');
win.buffer.attr({ height, width });
win.canvas.attr({ height, width });
}
this.send_event({ type: 'redraw' });
}
// If the gameport receives a click event, then find one window with active text input to focus
onclick() {
if (!this.glkote.disabled && no_text_selected()) {
for (const window of this.values()) {
if (window.inputs?.type) {
window.frameel.trigger('click');
break;
}
}
}
}
// On document.keypress events, redirect to a window
onkeydown = (ev) => {
// Don't fire on inputs or focused buffer windows
if (!this.glkote.disabled && ev.target.nodeName !== 'input' && !(ev.target.nodeName === 'div' && $(ev.target).is('.BufferWindow:focus'))) {
// Look first for a window with active text input, but as a fallback any buffer window
const windows = [...this.values()];
const window = windows.filter(win => win.inputs?.type)[0] || windows.filter(win => win.type === 'buffer')[0];
if (window) {
window.frameel.trigger('click');
// After focusing, the keypress event will fire, but not the keydown, meaning that function keys won't be recognised
// So manually trigger the keydown event in the input (as long as it is actually focused)
if (window.textinput.el.is(':focus')) {
window.textinput.el.trigger(ev);
}
// Otherwise focus the window so that nav keys will work
else if (window.type === 'buffer') {
window.frameel.trigger('focus');
}
}
}
};
update(windows) {
// Mark all windows as non-desired
for (const win of this.values()) {
win.desired = false;
}
// Go through each window in this update
for (const update of windows) {
const id = update.id;
const type = update.type;
// Is there an existing window?
let win = this.get(id);
// Create it if not
if (!win) {
win = new (window_classes[type])({
blorb: this.blorb,
dom: this.dom,
id,
manager: this,
metrics: this.metrics,
rock: update.rock,
styles: update.styles,
type,
});
this.set(id, win);
}
else {
if (win.type !== type) {
throw new Error(`Window ${id} was created with type ${win.type}, but now is described as type ${type}`);
}
}
win.desired = true;
// Ensure graphics windows are the right size
if (win.type === 'graphics') {
if (win.height !== update.graphheight || win.width !== update.graphwidth) {
win.set_dimensions(update.graphheight, update.graphwidth);
}
}
// Ensure grid windows have the right number of lines
if (win.type === 'grid') {
if (update.gridheight > win.height) {
for (let i = win.height; i < update.gridheight; i++) {
const line = this.dom.create('div', `win${win.id}_ln${i}`, 'GridLine');
line.append(NBSP);
win.frameel.append(line);
win.lines[i] = line;
}
}
if (update.gridheight < win.height) {
for (let i = update.gridheight; i < win.height; i++) {
this.dom.id(`win${win.id}_ln${i}`).remove();
}
}
win.height = update.gridheight;
win.lines.length = win.height;
win.width = update.gridwidth;
}
// Update the position of the window
win.frameel.css({
height: update.height,
left: update.left,
top: update.top,
width: update.width,
})
.toggleClass('hidden', !!update.hidden);
if (win.type === 'buffer') {
win.scroll_to_bottom(true);
}
}
const windowstoclose = [];
for (const win of this.values()) {
if (!win.desired) {
windowstoclose.push(win);
}
}
for (const win of windowstoclose) {
win.destroy(true);
this.delete(win.id);
}
}
update_content(content) {
for (const update of content) {
const win = this.get(update.id);
if (!win) {
throw new Error(`Got content update for window ${update.id}, which does not exist`);
}
// TODO: check for pending line input
switch (win.type) {
case 'buffer':
win.update(update);
break;
case 'graphics':
// Queue this frame, but only run the update function if this is the only frame
win.framequeue.push(update.draw);
if (win.framequeue.length === 1) {
win.update();
}
break;
case 'grid':
win.update(update);
break;
}
}
}
update_inputs(windows) {
for (const update of windows) {
const win = this.get(update.id);
if (!win) {
throw new Error(`Got input update for window ${update.id}, which does not exist`);
}
const oldgen = win.inputs?.gen;
win.inputs = update;
if (update.type) {
if (update.gen !== oldgen) {
win.textinput.update();
}
}
}
// Refocus an <input>
// 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
if (this.active_window) {
// Refocus the same window if it hasn't been deleted and still wants input
if (this.has(this.active_window.id) && this.active_window.inputs?.type) {
this.active_window.textinput.refocus();
}
// Look for any window with text input
else {
[...this.values()].filter(win => win.inputs?.type)[0]?.textinput.refocus();
}
}
}
}