| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| 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; |
| |
| saving: 'on' | 'off' | 'offUntilChange'; |
| savingEnabledCallback?: VoidFunction; |
| 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.saving = 'on'; |
| this.savingEnabledCallback = undefined; |
| this.lastInput = {}; |
| this.active = undefined; |
| this.refreshDelay = Infinity; |
| this.stopFn = undefined; |
| this.loaderElement = document.getElementById('Loader') as HTMLElement; |
| this.loaderTimeout = undefined; |
| this.deploymentCallback = undefined; |
| } |
| |
| disableSaving() { |
| this.saving = 'off'; |
| } |
| |
| disableSavingUntilNextChange(callback: VoidFunction) { |
| this.saving = 'offUntilChange'; |
| this.savingEnabledCallback = callback; |
| } |
| |
| 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; |
| } |
| |
| private save() { |
| if (this.saving === 'on') { |
| this.saveFn(); |
| } |
| } |
| |
| private 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 = () => { |
| if (this.saving === 'offUntilChange') { |
| this.saving = 'on'; |
| this.savingEnabledCallback!(); |
| this.savingEnabledCallback = undefined; |
| } |
| clearTimeout(idleTimeout); |
| idleTimeout = setTimeout(() => this.run(), this.refreshDelay); |
| }; |
| const unload = () => this.save(); |
| const blur = () => clearTimeout(idleTimeout); |
| const keydown = (event: KeyboardEvent) => { |
| if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { |
| clearTimeout(idleTimeout); |
| this.run(); |
| event.stopPropagation(); |
| } |
| }; |
| |
| 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.save(); |
| 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 as 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'); |
| } |
| } |
| |
| private startLoadingAnimation() { |
| clearTimeout(this.loaderTimeout); |
| this.loaderElement.style.display = 'block'; |
| this.loaderTimeout = setTimeout(() => { |
| this.loaderElement.style.opacity = '1'; |
| }, LOADER_OPACITY_1_DELAY); |
| } |
| |
| private endLoadingAnimation() { |
| clearTimeout(this.loaderTimeout); |
| this.loaderElement.style.opacity = '0'; |
| this.loaderTimeout = setTimeout(() => { |
| this.loaderElement.style.display = 'none'; |
| }, LOADER_DISPLAY_NONE_DELAY); |
| } |
| } |