UNPKG

vim-wasm

Version:

Vim editor ported to WebAssembly

1,162 lines (998 loc) 40 kB
/* vi:set ts=4 sts=4 sw=4 et: * * VIM - Vi IMproved by Bram Moolenaar * Wasm support by rhysd <https://github.com/rhysd> * * Do ":help uganda" in Vim to read copying and usage conditions. * Do ":help credits" in Vim to see a list of people who contributed. * See README.txt for an overview of the Vim source code. */ /* * vimwasm.ts: TypeScript main thread module for Wasm port of Vim by @rhysd. */ /// <reference path="common.d.ts"/> type PerfMark = 'init' | 'raf' | 'draw'; type MessageEncodable = number | boolean | string; export interface ScreenDrawer { draw(msg: DrawEventMessage): void; onVimInit(): void; onVimExit(): void; getDomSize(): { width: number; height: number }; setPerf(enabled: boolean): void; focus(): void; } export interface KeyModifiers { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean; } export const VIM_VERSION = '8.2.0055'; const AsyncFunction = Object.getPrototypeOf(async function() { /* do nothing */ }).constructor; function noop() { /* do nothing */ } let debug: (...args: any[]) => void = noop; const STATUS_NOT_SET = 0 as const; const STATUS_NOTIFY_KEY = 1 as const; const STATUS_NOTIFY_RESIZE = 2 as const; const STATUS_NOTIFY_OPEN_FILE_BUF_COMPLETE = 3 as const; const STATUS_NOTIFY_CLIPBOARD_WRITE_COMPLETE = 4 as const; const STATUS_REQUEST_CMDLINE = 5 as const; const STATUS_REQUEST_SHARED_BUF = 6 as const; const STATUS_NOTIFY_ERROR_OUTPUT = 7 as const; const STATUS_NOTIFY_EVAL_FUNC_RET = 8 as const; function statusName(s: EventStatusFromMain): string { switch (s) { case STATUS_NOT_SET: return 'NOT_SET'; case STATUS_NOTIFY_KEY: return 'NOTIFY_KEY'; case STATUS_NOTIFY_RESIZE: return 'NOTIFY_RESIZE'; case STATUS_NOTIFY_OPEN_FILE_BUF_COMPLETE: return 'NOTIFY_OPEN_FILE_BUF_COMPLETE'; case STATUS_NOTIFY_CLIPBOARD_WRITE_COMPLETE: return 'NOTIFY_CLIPBOARD_WRITE_COMPLETE'; case STATUS_REQUEST_CMDLINE: return 'REQUEST_CMDLINE'; case STATUS_REQUEST_SHARED_BUF: return 'REQUEST_SHARED_BUF'; case STATUS_NOTIFY_ERROR_OUTPUT: return 'NOTIFY_ERROR_OUTPUT'; case STATUS_NOTIFY_EVAL_FUNC_RET: return 'STATUS_NOTIFY_EVAL_FUNC_RET'; default: return `Unknown command: ${s}`; } } export function checkBrowserCompatibility(): string | undefined { function notSupported(feat: string): string { return `${feat} is not supported by this browser. If you're using Firefox or Safari, please enable feature flag.`; } if (typeof SharedArrayBuffer === 'undefined') { return notSupported('SharedArrayBuffer'); } if (typeof Atomics === 'undefined') { return notSupported('Atomics API'); } return undefined; } export class VimWorker { public debug: boolean; public readonly sharedBuffer: Int32Array; private readonly worker: Worker; private readonly onMessage: (msg: MessageFromWorker) => void; private readonly onError: (err: Error) => void; private readonly onOneshotMessage: Map<MessageKindFromWorker, (msg: MessageFromWorker) => void>; // Events are queued since an event may arrive while previous event is still being processed by // worker thread. First element is a pair of status and values of an event currently being processed. // Rest elements are pending events which will be processed after. private readonly pendingEvents: Array<[EventStatusFromMain, MessageEncodable[]]>; constructor(scriptPath: string, onMessage: (msg: MessageFromWorker) => void, onError: (err: Error) => void) { this.worker = new Worker(scriptPath); this.worker.onmessage = this.recvMessage.bind(this); this.worker.onerror = this.recvError.bind(this); this.sharedBuffer = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 128)); this.onMessage = onMessage; this.onError = onError; this.onOneshotMessage = new Map(); this.debug = false; this.pendingEvents = []; } terminate() { this.worker.terminate(); this.worker.onmessage = null; debug('Terminated worker thread. Thank you for working hard!'); } sendStartMessage(msg: StartMessageFromMain) { this.worker.postMessage(msg); debug('Sent start message', msg); } notifyOpenFileBufComplete(filename: string, bufId: number) { this.enqueueEvent(STATUS_NOTIFY_OPEN_FILE_BUF_COMPLETE, bufId, filename); } notifyClipboardWriteComplete(cannotSend: boolean, bufId: number) { this.enqueueEvent(STATUS_NOTIFY_CLIPBOARD_WRITE_COMPLETE, cannotSend, bufId); } notifyKeyEvent(key: string, keyCode: number, ctrl: boolean, shift: boolean, alt: boolean, meta: boolean) { this.enqueueEvent(STATUS_NOTIFY_KEY, keyCode, ctrl, shift, alt, meta, key); } notifyResizeEvent(width: number, height: number) { this.enqueueEvent(STATUS_NOTIFY_RESIZE, width, height); } async requestSharedBuffer(byteLength: number): Promise<[number, SharedArrayBuffer]> { this.enqueueEvent(STATUS_REQUEST_SHARED_BUF, byteLength); const msg = (await this.waitForOneshotMessage('shared-buf:response')) as SharedBufResponseFromWorker; if (msg.buffer.byteLength !== byteLength) { throw new Error( `Size of shared buffer from worker ${msg.buffer.byteLength} bytes mismatches to requested size ${byteLength} bytes`, ); } return [msg.bufId, msg.buffer]; } notifyClipboardError() { this.notifyClipboardWriteComplete(true, 0); debug('Reading clipboard failed. Notify it to worker'); } async responseClipboardText(text: string) { const encoded = new TextEncoder().encode(text); const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength + 1); // `+ 1` for NULL termination new Uint8Array(buffer).set(encoded); this.notifyClipboardWriteComplete(false, bufId); debug('Wrote clipboard', encoded.byteLength, 'bytes text and notified to worker'); } async requestCmdline(cmdline: string) { if (cmdline.length === 0) { throw new Error('Specified command line is empty'); } this.enqueueEvent(STATUS_REQUEST_CMDLINE, cmdline); const msg = (await this.waitForOneshotMessage('cmdline:response')) as CmdlineResultFromWorker; debug('Result of command', cmdline, ':', msg.success); if (!msg.success) { throw Error(`Command '${cmdline}' was invalid and not accepted by Vim`); } } async notifyErrorOutput(message: string) { const encoded = new TextEncoder().encode(message); const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); new Uint8Array(buffer).set(encoded); this.enqueueEvent(STATUS_NOTIFY_ERROR_OUTPUT, bufId); debug('Sent error message output:', message); } async notifyEvalFuncRet(ret: string) { const encoded = new TextEncoder().encode(ret); const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); new Uint8Array(buffer).set(encoded); this.enqueueEvent(STATUS_NOTIFY_EVAL_FUNC_RET, false /*isError*/, bufId); debug('Sent return value of evaluated JS function:', ret); } async notifyEvalFuncError(msg: string, err: Error, dontReply: boolean) { const errmsg = `${msg} for jsevalfunc(): ${err.message}: ${err.stack}`; if (dontReply) { debug('Will send error output from jsevalfunc() though the invocation was notify-only:', errmsg); return this.notifyErrorOutput(errmsg); } const encoded = new TextEncoder().encode('E9999: ' + errmsg); const [bufId, buffer] = await this.requestSharedBuffer(encoded.byteLength); new Uint8Array(buffer).set(encoded); this.enqueueEvent(STATUS_NOTIFY_EVAL_FUNC_RET, true /*isError*/, bufId); debug('Sent exception thrown by evaluated JS function:', msg, err); } onEventDone(doneStatus: EventStatusFromMain) { const done = statusName(doneStatus); // First element should be an event being processed by worker. // Dequeue it and check it matches to the status notified from worker. const finished = this.pendingEvents.shift(); if (finished === undefined) { throw new Error(`FATAL: Received ${done} event but event queue is empty`); } if (finished[0] !== doneStatus) { throw new Error( `FATAL: Received ${done} event but queue says previous event was ${statusName(finished[0])} with args ${ finished[1] }`, ); } // Send next pending event if exists if (this.pendingEvents.length === 0) { debug('No pending event remains after event', done); return; } debug('After', done, 'event, still', this.pendingEvents.length, 'events are pending'); const [status, values] = this.pendingEvents[0]; this.sendEvent(status, values); } private enqueueEvent(status: EventStatusFromMain, ...values: MessageEncodable[]) { this.pendingEvents.push([status, values]); if (this.pendingEvents.length > 1) { debug('Other event is being handled by worker. Pending:', statusName(status), values); return; } // When queue was empty, send the event immediately this.sendEvent(status, values); } private sendEvent(status: EventStatusFromMain, values: MessageEncodable[]) { const event = statusName(status); // TODO: Queueing request/notification to worker and wait status byte is cleared // Note: Non-zero means data remains not handled by worker yet. if (this.debug) { const status = Atomics.load(this.sharedBuffer, 0); if (status !== STATUS_NOT_SET) { console.error('INVARIANT ERROR! Status byte must be zero cleared:', event); // eslint-disable-line no-console } } debug('Write event', event, 'payload to buffer:', values); let idx = 0; this.sharedBuffer[idx++] = status; for (const value of values) { switch (typeof value) { case 'string': idx = this.encodeStringToBuffer(value, idx); break; case 'number': this.sharedBuffer[idx++] = value; break; case 'boolean': this.sharedBuffer[idx++] = +value; break; default: throw new Error(`FATAL: Invalid value for payload to worker: ${value}`); } } debug('Wrote', idx * 4, 'bytes to buffer for event', event); Atomics.notify(this.sharedBuffer, 0, 1); debug('Notified event', event, 'to worker'); } private async waitForOneshotMessage(kind: MessageKindFromWorker) { return new Promise<MessageFromWorker>(resolve => { this.onOneshotMessage.set(kind, resolve); }); } private encodeStringToBuffer(s: string, startIdx: number) { let idx = startIdx; const len = s.length; this.sharedBuffer[idx++] = len; for (let i = 0; i < len; ++i) { this.sharedBuffer[idx++] = s.charCodeAt(i); } return idx; } private recvMessage(e: MessageEvent) { const msg: MessageFromWorker = e.data; // Handle oneshot communication for RPC call const handler = this.onOneshotMessage.get(msg.kind); if (handler !== undefined) { this.onOneshotMessage.delete(msg.kind); handler(msg); return; } // On notification this.onMessage(msg); } private recvError(e: ErrorEvent) { debug('Received an error from worker:', e); const msg = `${e.message} (${e.filename}:${e.lineno}:${e.colno})`; this.onError(new Error(msg)); } } export class ResizeHandler { elemHeight: number; elemWidth: number; private bounceTimerToken: number | null; private readonly canvas: HTMLCanvasElement; private readonly worker: VimWorker; constructor(domWidth: number, domHeight: number, canvas: HTMLCanvasElement, worker: VimWorker) { this.canvas = canvas; this.worker = worker; this.elemHeight = domHeight; this.elemWidth = domWidth; const dpr = window.devicePixelRatio || 1; this.canvas.width = domWidth * dpr; this.canvas.height = domHeight * dpr; this.bounceTimerToken = null; this.onResize = this.onResize.bind(this); } onVimInit() { window.addEventListener('resize', this.onResize, { passive: true }); } onVimExit() { window.removeEventListener('resize', this.onResize); } private doResize() { const rect = this.canvas.getBoundingClientRect(); debug('Resize Vim:', rect); this.elemWidth = rect.width; this.elemHeight = rect.height; const res = window.devicePixelRatio || 1; this.canvas.width = rect.width * res; this.canvas.height = rect.height * res; this.worker.notifyResizeEvent(rect.width, rect.height); } private onResize() { if (this.bounceTimerToken !== null) { window.clearTimeout(this.bounceTimerToken); } this.bounceTimerToken = window.setTimeout(() => { this.bounceTimerToken = null; this.doResize(); }, 500); } } // TODO: IME support // TODO: Handle pre-edit IME state // TODO: Follow cursor position export class InputHandler { private readonly worker: VimWorker; private readonly elem: HTMLInputElement; constructor(worker: VimWorker, input: HTMLInputElement) { this.worker = worker; this.elem = input; // TODO: Bind compositionstart event // TODO: Bind compositionend event this.onKeydown = this.onKeydown.bind(this); this.onBlur = this.onBlur.bind(this); this.onFocus = this.onFocus.bind(this); this.focus(); } setFont(name: string, size: number) { this.elem.style.fontFamily = name; this.elem.style.fontSize = `${size}px`; } focus() { this.elem.focus(); } onVimInit() { this.elem.addEventListener('keydown', this.onKeydown, { capture: true }); this.elem.addEventListener('blur', this.onBlur); this.elem.addEventListener('focus', this.onFocus); } onVimExit() { this.elem.removeEventListener('keydown', this.onKeydown); this.elem.removeEventListener('blur', this.onBlur); this.elem.removeEventListener('focus', this.onFocus); } private onKeydown(event: KeyboardEvent) { event.preventDefault(); event.stopPropagation(); debug('onKeydown():', event, event.key, event.keyCode); let key = event.key; const ctrl = event.ctrlKey; const shift = event.shiftKey; const alt = event.altKey; const meta = event.metaKey; if (key.length > 1) { if ( key === 'Unidentified' || (ctrl && key === 'Control') || (shift && key === 'Shift') || (alt && key === 'Alt') || (meta && key === 'Meta') ) { debug('Ignore key input', key); return; } } // Note: Yen needs to be fixed to backslash // Note: Also check event.code since Ctrl + yen is recognized as Ctrl + | due to Chrome bug. // https://bugs.chromium.org/p/chromium/issues/detail?id=871650 if (key === '\u00A5' || (!shift && key === '|' && event.code === 'IntlYen')) { key = '\\'; } this.worker.notifyKeyEvent(key, event.keyCode, ctrl, shift, alt, meta); } private onFocus() { debug('onFocus()'); // TODO: Send <FocusGained> special character } private onBlur(event: Event) { debug('onBlur():', event); event.preventDefault(); // TODO: Send <FocusLost> special character } } // Origin is at left-above. // // O-------------> x // | // | // | // | // V // y export class ScreenCanvas implements DrawEventHandler, ScreenDrawer { private readonly worker: VimWorker; private readonly canvas: HTMLCanvasElement; private readonly ctx: CanvasRenderingContext2D; private readonly input: InputHandler; private readonly queue: DrawEventMessage[]; private readonly resizer: ResizeHandler; private perf: boolean; private fgColor: string; private spColor: string; private fontName: string; private rafScheduled: boolean; // Note: BG color is actually unused because color information is included // in drawRect event arguments // private bgColor: string; constructor(worker: VimWorker, canvas: HTMLCanvasElement, input: HTMLInputElement) { this.worker = worker; this.canvas = canvas; const ctx = this.canvas.getContext('2d', { alpha: false }); if (ctx === null) { throw new Error('Cannot get 2D context for <canvas>'); } this.ctx = ctx; const rect = this.canvas.getBoundingClientRect(); const res = window.devicePixelRatio || 1; this.canvas.width = rect.width * res; this.canvas.height = rect.height * res; this.canvas.addEventListener('click', this.onClick.bind(this), { capture: true, passive: true, }); this.input = new InputHandler(this.worker, input); this.resizer = new ResizeHandler(rect.width, rect.height, canvas, worker); this.onAnimationFrame = this.onAnimationFrame.bind(this); this.queue = []; this.rafScheduled = false; this.perf = false; this.fgColor = ''; this.spColor = ''; this.fontName = ''; } onVimInit() { this.input.onVimInit(); this.resizer.onVimInit(); } onVimExit() { this.input.onVimExit(); this.resizer.onVimExit(); } draw(msg: DrawEventMessage) { if (!this.rafScheduled) { window.requestAnimationFrame(this.onAnimationFrame); this.rafScheduled = true; } this.queue.push(msg); } focus() { this.input.focus(); } getDomSize() { return { width: this.resizer.elemWidth, height: this.resizer.elemHeight, }; } setPerf(enabled: boolean) { this.perf = enabled; } setColorFG(name: string) { this.fgColor = name; } setColorBG(_name: string) { // Note: BG color is actually unused because color information is included // in drawRect event arguments // this.bgColor = name; } setColorSP(name: string) { this.spColor = name; } setFont(name: string, size: number) { this.fontName = name; this.input.setFont(name, size); } drawRect(x: number, y: number, w: number, h: number, color: string, filled: boolean) { const dpr = window.devicePixelRatio || 1; x = Math.floor(x * dpr); y = Math.floor(y * dpr); w = Math.floor(w * dpr); h = Math.floor(h * dpr); this.ctx.fillStyle = color; if (filled) { this.ctx.fillRect(x, y, w, h); } else { this.ctx.rect(x, y, w, h); } } drawText( text: string, ch: number, lh: number, cw: number, x: number, y: number, bold: boolean, underline: boolean, undercurl: boolean, strike: boolean, ) { const dpr = window.devicePixelRatio || 1; ch = ch * dpr; lh = lh * dpr; cw = cw * dpr; x = x * dpr; y = y * dpr; let font = `${Math.floor(ch)}px ${this.fontName}`; if (bold) { font = 'bold ' + font; } this.ctx.font = font; // Note: 'ideographic' is not available (#23) // https://twitter.com/Linda_pp/status/1139373687474278400 this.ctx.textBaseline = 'bottom'; this.ctx.fillStyle = this.fgColor; const descent = (lh - ch) / 2; const yi = Math.floor(y + lh - descent); for (let i = 0; i < text.length; ++i) { const c = text[i]; if (c === ' ') { // Note: Skip rendering whitespace // XXX: This optimization assumes current font renders nothing on whitespace. continue; } this.ctx.fillText(c, Math.floor(x + cw * i), yi); } if (underline) { this.ctx.strokeStyle = this.fgColor; this.ctx.lineWidth = 1 * dpr; this.ctx.setLineDash([]); this.ctx.beginPath(); // Note: 3 is set with considering the width of line. const underlineY = Math.floor(y + lh - descent - 1 * dpr); this.ctx.moveTo(Math.floor(x), underlineY); this.ctx.lineTo(Math.floor(x + cw * text.length), underlineY); this.ctx.stroke(); } else if (undercurl) { this.ctx.strokeStyle = this.spColor; this.ctx.lineWidth = 1 * dpr; const curlWidth = Math.floor(cw / 3); this.ctx.setLineDash([curlWidth, curlWidth]); this.ctx.beginPath(); // Note: 3 is set with considering the width of line. const undercurlY = Math.floor(y + lh - descent - 1 * dpr); this.ctx.moveTo(Math.floor(x), undercurlY); this.ctx.lineTo(Math.floor(x + cw * text.length), undercurlY); this.ctx.stroke(); } else if (strike) { this.ctx.strokeStyle = this.fgColor; this.ctx.lineWidth = 1 * dpr; this.ctx.beginPath(); const strikeY = Math.floor(y + lh / 2); this.ctx.moveTo(Math.floor(x), strikeY); this.ctx.lineTo(Math.floor(x + cw * text.length), strikeY); this.ctx.stroke(); } } invertRect(x: number, y: number, w: number, h: number) { const dpr = window.devicePixelRatio || 1; x = Math.floor(x * dpr); y = Math.floor(y * dpr); w = Math.floor(w * dpr); h = Math.floor(h * dpr); const img = this.ctx.getImageData(x, y, w, h); const data = img.data; const len = data.length; for (let i = 0; i < len; ++i) { data[i] = 255 - data[i]; ++i; data[i] = 255 - data[i]; ++i; data[i] = 255 - data[i]; ++i; // Skip alpha } this.ctx.putImageData(img, x, y); } imageScroll(x: number, sy: number, dy: number, w: number, h: number) { const dpr = window.devicePixelRatio || 1; x = Math.floor(x * dpr); sy = Math.floor(sy * dpr); dy = Math.floor(dy * dpr); w = Math.floor(w * dpr); h = Math.floor(h * dpr); this.ctx.drawImage(this.canvas, x, sy, w, h, x, dy, w, h); } private onClick() { this.input.focus(); } private onAnimationFrame() { debug('Rendering', this.queue.length, 'events on animation frame'); this.perfMark('raf'); for (const [method, args] of this.queue) { this.perfMark('draw'); this[method].apply(this, args); this.perfMeasure('draw', `draw:${method}`); } this.queue.length = 0; // Clear queue this.rafScheduled = false; this.perfMeasure('raf'); } private perfMark(m: PerfMark) { if (this.perf) { performance.mark(m); } } private perfMeasure(m: PerfMark, n?: string) { if (this.perf) { performance.measure(n ?? m, m); performance.clearMarks(m); } } } export interface StartOptions { debug?: boolean; perf?: boolean; clipboard?: boolean; persistentDirs?: string[]; dirs?: string[]; files?: { [fpath: string]: string }; fetchFiles?: { [fpath: string]: string }; cmdArgs?: string[]; } export interface OptionsRenderToDOM { canvas: HTMLCanvasElement; input: HTMLInputElement; workerScriptPath: string; } export interface OptionsUserRenderer { screen: ScreenDrawer; workerScriptPath: string; } export type VimWasmConstructOptions = OptionsRenderToDOM | OptionsUserRenderer; export class VimWasm { public onVimInit?: () => void; public onVimExit?: (status: number) => void; public onFileExport?: (fullpath: string, contents: ArrayBuffer) => void; public onError?: (err: Error) => void; public readClipboard?: () => Promise<string>; public onWriteClipboard?: (text: string) => void; public onTitleUpdate?: (title: string) => void; private readonly worker: VimWorker; private readonly screen: ScreenDrawer; private perf: boolean; private debug: boolean; private perfMessages: { [name: string]: number[] }; private running: boolean; private end: boolean; constructor(opts: VimWasmConstructOptions) { const script = opts.workerScriptPath; if (!script) { throw new Error("'workerScriptPath' option is required"); } this.handleError = this.handleError.bind(this); this.worker = new VimWorker(script, this.onMessage.bind(this), this.handleError); if ('canvas' in opts && 'input' in opts) { this.screen = new ScreenCanvas(this.worker, opts.canvas, opts.input); } else if ('screen' in opts) { this.screen = opts.screen; } else { throw new Error('Invalid options for VimWasm construction: ' + JSON.stringify(opts)); } this.perf = false; this.debug = false; this.perfMessages = {}; this.running = false; this.end = false; } start(opts?: StartOptions) { if (this.running || this.end) { throw new Error('Cannot start Vim twice'); } const o = opts ?? { clipboard: navigator.clipboard !== undefined }; if (o.debug) { debug = console.log.bind(console, 'main:'); // eslint-disable-line no-console this.worker.debug = true; } this.perf = !!o.perf; this.debug = !!o.debug; this.screen.setPerf(this.perf); this.running = true; this.perfMark('init'); const { width, height } = this.screen.getDomSize(); const msg: StartMessageFromMain = { kind: 'start', buffer: this.worker.sharedBuffer, canvasDomWidth: width, canvasDomHeight: height, debug: this.debug, perf: this.perf, clipboard: !!o.clipboard, files: o.files ?? {}, dirs: o.dirs ?? [], fetchFiles: o.fetchFiles ?? {}, persistent: o.persistentDirs ?? [], cmdArgs: o.cmdArgs ?? [], }; this.worker.sendStartMessage(msg); debug('Started with drawer', this.screen); } // Note: Sending file to Vim requires some message interactions. // // 1. Main sends FILE_REQUEST event with file size and file name to worker via shared memory buffer // 2. Worker waits the event with Atomics.wait() and gets the size and name // 3. Worker allocates a new SharedArrayBuffer with the file size // 4. Worker sends the buffer to main via 'file-buffer' message using postMessage() // 5. Main receives the message and copy file contents to the buffer // 6. Main sends FILE_WRITE_COMPLETE event to worker via shared memory buffer // 7. Worker waits the event with Atomics.wait() // 8. Worker reads file contents from the buffer alolocated at 3. and deletes the buffer // 9. Worker handles the file open // // This a bit complex interactions are necessary because postMessage() from main thread does // not work. Worker sleeps in Vim's main loop using Atomics.wait(). So JavaScript context in worker // never ends until exit() is called. It means that onmessage callback is never fired. async dropFile(name: string, contents: ArrayBuffer) { if (!this.running) { throw new Error('Cannot open file since Vim is not running'); } debug('Handling to open file', name, contents); // Get shared buffer to write file contents from worker const [bufId, buffer] = await this.worker.requestSharedBuffer(contents.byteLength); // Write file contents new Uint8Array(buffer).set(new Uint8Array(contents)); // Notify worker to start processing the file contents this.worker.notifyOpenFileBufComplete(name, bufId); debug('Wrote file', name, 'to', contents.byteLength, 'bytes buffer and notified it to worker'); } async dropFiles(files: FileList) { const reader = new FileReader(); for (const file of files) { const [name, contents] = await this.readFile(reader, file); await this.dropFile(name, contents); } } resize(pixelWidth: number, pixelHeight: number) { this.worker.notifyResizeEvent(pixelWidth, pixelHeight); } sendKeydown(key: string, keyCode: number, modifiers?: KeyModifiers) { const { ctrl = false, shift = false, alt = false, meta = false } = modifiers ?? {}; if (key.length > 1) { if ( key === 'Unidentified' || (ctrl && key === 'Control') || (shift && key === 'Shift') || (alt && key === 'Alt') || (meta && key === 'Meta') ) { debug('Ignore key input', key); return; } } this.worker.notifyKeyEvent(key, keyCode, ctrl, shift, alt, meta); } // Note: This command execution does not trigger screen redraw. // Please run :redraw like "1put | redraw" if updating screen is necessary. cmdline(cmdline: string): Promise<void> { return this.worker.requestCmdline(cmdline); } isRunning() { return this.running; } focus() { this.screen.focus(); } showError(message: string) { return this.worker.notifyErrorOutput(message); } private async readFile(reader: FileReader, file: File) { return new Promise<[string, ArrayBuffer]>((resolve, reject) => { reader.onload = f => { debug('Read file', file.name, 'from D&D:', f); resolve([file.name, reader.result as ArrayBuffer]); }; reader.onerror = () => { reader.abort(); reject(new Error(`Error on loading file ${file}`)); }; reader.readAsArrayBuffer(file); }); } private async evalJS(path: string, contents: ArrayBuffer) { debug('Evaluating JavaScript file', path, 'with size', contents.byteLength, 'bytes'); const dec = new TextDecoder(); const src = '"use strict";' + dec.decode(contents); try { // Function() is better option to evaluate JavaScript source with global scope rather than eval(). // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#Do_not_ever_use_eval! Function(src)(); } catch (err) { debug('Failed to evaluate', path, 'with error:', err); await this.showError(`${err.message}\n\n${err.stack}`); } } private async evalFunc(body: string, args: any[], notifyOnly: boolean) { debug('Evaluating JavaScript function:', body, args); let f; try { f = new AsyncFunction(body); } catch (err) { return this.worker.notifyEvalFuncError('Could not construct function', err, notifyOnly); } let ret; try { ret = await f(...args); } catch (err) { return this.worker.notifyEvalFuncError('Exception was thrown while evaluating function', err, notifyOnly); } if (notifyOnly) { debug('Evaluated JavaScript result was discarded since the message was notify-only:', ret, body); return Promise.resolve(); } let retJson; try { retJson = JSON.stringify(ret); } catch (err) { return this.worker.notifyEvalFuncError( 'Could not serialize return value as JSON from function', err, false, ); } return this.worker.notifyEvalFuncRet(retJson); } private onMessage(msg: MessageFromWorker) { if (this.perf && msg.timestamp !== undefined) { // performance.now() is not available because time origin is different between Window and Worker const duration = Date.now() - msg.timestamp; const name = msg.kind === 'draw' ? `draw:${msg.event[0]}` : msg.kind; const timestamps = this.perfMessages[name]; if (timestamps === undefined) { this.perfMessages[name] = [duration]; } else { this.perfMessages[name].push(duration); } } switch (msg.kind) { case 'draw': this.screen.draw(msg.event); debug('draw event', msg.event); break; case 'done': this.worker.onEventDone(msg.status); break; case 'evalfunc': { const args = msg.argsJson === undefined ? [] : JSON.parse(msg.argsJson); this.evalFunc(msg.body, args, msg.notifyOnly).catch(this.handleError); break; } case 'title': if (this.onTitleUpdate) { debug('title was updated:', msg.title); this.onTitleUpdate(msg.title); } break; case 'read-clipboard:request': if (this.readClipboard) { this.readClipboard() .then(text => this.worker.responseClipboardText(text)) .catch(err => { debug('Cannot read clipboard:', err); this.worker.notifyClipboardError(); }); } else { debug('Cannot read clipboard because VimWasm.readClipboard is not set'); this.worker.notifyClipboardError(); } break; case 'write-clipboard': debug('Handle writing text', msg.text, 'to clipboard with', this.onWriteClipboard); if (this.onWriteClipboard) { this.onWriteClipboard(msg.text); } break; case 'export': if (this.onFileExport !== undefined) { debug('Exporting file', msg.path, 'with size in bytes', msg.contents.byteLength); this.onFileExport(msg.path, msg.contents); } break; case 'eval': this.evalJS(msg.path, msg.contents).catch(this.handleError); break; case 'started': this.screen.onVimInit(); if (this.onVimInit) { this.onVimInit(); } this.perfMeasure('init'); debug('Vim started'); break; case 'exit': this.screen.onVimExit(); this.printPerfs(); this.worker.terminate(); if (this.onVimExit) { this.onVimExit(msg.status); } debug('Vim exited with status', msg.status); this.perf = false; this.debug = false; this.screen.setPerf(false); this.running = false; this.end = true; break; case 'error': debug('Vim threw an error:', msg.message); this.handleError(new Error(msg.message)); this.worker.terminate(); break; default: throw new Error(`Unexpected message from worker: ${JSON.stringify(msg)}`); } } private handleError(err: Error) { if (this.onError) { this.onError(err); } } private printPerfs() { if (!this.perf) { return; } { const measurements = new Map<string, PerformanceEntry[]>(); for (const e of performance.getEntries()) { const ms = measurements.get(e.name); if (ms === undefined) { measurements.set(e.name, [e]); } else { ms.push(e); } } const averages: { [name: string]: number } = {}; const amounts: { [name: string]: number } = {}; const timings: PerformanceEntry[] = []; for (const [name, ms] of measurements) { if (ms.length === 1 && ms[0].entryType !== 'measure') { timings.push(ms[0]); continue; } /* eslint-disable no-console */ console.log(`%c${name}`, 'color: green; font-size: large'); console.table(ms, ['duration', 'startTime']); /* eslint-enable no-console */ const total = ms.reduce((a, m) => a + m.duration, 0); averages[name] = total / ms.length; amounts[name] = total; } /* eslint-disable no-console */ console.log('%cTimings (ms)', 'color: green; font-size: large'); console.table(timings, ['name', 'entryType', 'startTime', 'duration']); console.log('%cAmount: Perf Mark Durations (ms)', 'color: green; font-size: large'); console.table(amounts); console.log('%cAverage: Perf Mark Durations (ms)', 'color: green; font-size: large'); console.table(averages); /* eslint-enable no-console */ performance.clearMarks(); performance.clearMeasures(); } { const averages: { [name: string]: number } = {}; for (const name of Object.keys(this.perfMessages)) { const durations = this.perfMessages[name]; const total = durations.reduce((a, d) => a + d, 0); averages[name] = total / durations.length; } // Note: Amounts of durations of inter-thread messages don't make sense since messaging is asynchronous. Multiple messages are sent and processed /* eslint-disable no-console */ console.log('%cAverage: Inter-thread Messages Duration (ms)', 'color: green; font-size: large'); console.table(averages); /* eslint-enable no-console */ this.perfMessages = {}; } } private perfMark(m: PerfMark) { if (this.perf) { performance.mark(m); } } private perfMeasure(m: PerfMark) { if (this.perf) { performance.measure(m, m); performance.clearMarks(m); } } }