blob: 9d28ec7268e4ff35ae78e934e78ac894e5907dd4 [file] [log] [blame]
import {Annotation, Editors, Options} from './editors';
import {InputMode, OutputMode} from './mode';
import {Timeout, withTimeout} from './util';
// Timeout for POST requests to the server, in milliseconds.
const POST_REQUEST_TIMEOUT = 15000;
// Delay from unhiding the loader to starting its opacity animation, in
// milliseconds. Too short and the CSS transition doesn't get triggered.
const LOADER_OPACITY_1_DELAY = 20;
// Delay from starting the loader fade-out transition to hiding it, in
// milliseconds. This should be at least as long as the CSS .loader transition.
const LOADER_DISPLAY_NONE_DELAY = 300;
// A request sent to the server.
interface Request {
inputMode: InputMode;
outputMode: OutputMode;
options: Options;
content: string;
}
// A type used to store past inputs. New inputs are checked to avoid sending
// requests to the server when nothing has changed.
type InputMap = Partial<
Record<
InputMode,
Partial<
Record<
OutputMode,
{
options: string;
content: string;
}
>
>
>
>;
// The server sends deployment information with every response. There is no need
// to specify its fields here because we just forward it to Elm.
type Deployment = object;
// Evaluator sends requests to the server to update the output.
export class Evaluator {
readonly editors: Editors;
readonly saveFn: VoidFunction;
readonly loaderElement: HTMLElement;
refreshDelay: number;
stopFn?: VoidFunction;
lastInput: InputMap;
active?: Omit<Request, 'content'>;
loaderTimeout?: Timeout;
deploymentCallback?: (deployment: Deployment) => void;
constructor(editors: Editors, saveFn: VoidFunction) {
this.editors = editors;
this.saveFn = saveFn;
this.lastInput = {};
this.active = undefined;
this.refreshDelay = Infinity;
this.stopFn = undefined;
this.loaderElement = document.getElementById('Loader') as HTMLElement;
this.loaderTimeout = undefined;
this.deploymentCallback = undefined;
}
onDeloymentUpdated(callback: (deployment: Deployment) => void) {
this.deploymentCallback = callback;
}
setActive(inputMode: InputMode, outputMode: OutputMode, options: Options) {
this.active = {inputMode, outputMode, options};
}
setRefreshDelay(delay: number) {
this.refreshDelay = delay;
}
needsRefresh(request: Request) {
const forOutput =
this.lastInput[request.inputMode] ??
(this.lastInput[request.inputMode] = {});
const last = forOutput[request.outputMode];
if (
last != undefined &&
last.content === request.content &&
last.options === JSON.stringify(request.options)
) {
return false;
}
forOutput[request.outputMode] = {
content: request.content,
options: JSON.stringify(request.options),
};
return true;
}
// Adds event listeners and starts the idle timeout, so that run() gets called
// on Ctrl+Enter or Cmd+Enter and this.refreshDelay milliseconds after the
// user types something in the input editor.
//
// This can be called again after stop() to restart. Calling start() a second
// time without first calling stop() has no effect.
start() {
if (this.stopFn != undefined) {
// Already started.
return;
}
let idleTimeout: Timeout;
const change = () => {
clearTimeout(idleTimeout);
idleTimeout = setTimeout(() => this.run(), this.refreshDelay);
};
const unload = () => this.saveFn();
const blur = () => clearTimeout(idleTimeout);
const keydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
clearTimeout(idleTimeout);
this.run();
}
};
this.editors.inputEditor.on('change', change);
window.addEventListener('unload', unload);
window.addEventListener('blur', blur);
window.addEventListener(
'keydown',
keydown,
// Use capturing rather than bubbling so that the Ace editor doesn't get
// the keydown event first.
true
);
this.stopFn = () => {
clearTimeout(idleTimeout);
this.editors.inputEditor.removeEventListener('change', change);
window.removeEventListener('unload', unload);
window.removeEventListener('blur', blur);
window.removeEventListener('keydown', keydown, true);
};
}
// Stops evaluating: removes all event listeners and cancels the idle timer.
stop() {
if (this.stopFn != undefined) {
this.stopFn();
this.stopFn = undefined;
}
}
// Sends a request to the server (if the input has changed) and updates the
// output, displaying a loading animation in the meantime.
async run() {
if (this.active == undefined) {
return;
}
const request = {
...this.active,
content: this.editors.inputEditor.getValue(),
};
if (!this.needsRefresh(request)) {
return;
}
this.startLoadingAnimation();
const finish = (
content: string,
anns?: Annotation[],
deployment?: object,
error?: string
) => {
if (deployment != undefined && this.deploymentCallback != undefined) {
this.deploymentCallback(deployment);
}
this.editors.setOutput(
request.inputMode,
request.outputMode,
content,
anns ?? [],
error
);
this.endLoadingAnimation();
};
this.saveFn();
let response;
try {
response = await withTimeout(
POST_REQUEST_TIMEOUT,
'Timed out waiting for server to respond',
fetch('/convert', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(request),
})
);
} catch (error) {
finish(error.message, [], undefined, 'Network Error');
return;
}
if (response.ok) {
const json = await response.json();
finish(
json.content,
json.annotations,
json.deployment,
json.ok ? undefined : 'Error'
);
} else {
let msg;
if (response.headers.get('Content-Type')?.startsWith('text/plain')) {
msg = await response.text();
} else {
msg = `Server responded with HTTP status ${response.status}`;
if (response.statusText) {
msg += ' ' + response.statusText;
}
}
finish(msg, [], undefined, 'Server Error');
}
}
startLoadingAnimation() {
clearTimeout(this.loaderTimeout);
this.loaderElement.style.display = 'block';
this.loaderTimeout = setTimeout(() => {
this.loaderElement.style.opacity = '1';
}, LOADER_OPACITY_1_DELAY);
}
endLoadingAnimation() {
clearTimeout(this.loaderTimeout);
this.loaderElement.style.opacity = '0';
this.loaderTimeout = setTimeout(() => {
this.loaderElement.style.display = 'none';
}, LOADER_DISPLAY_NONE_DELAY);
}
}