From 37017f6c3f08c91e127db62f225103248f23d7c4 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Wed, 14 Jul 2021 15:29:17 +0000 Subject: [PATCH] Add TypeScript checks, fix a few issues --- .gitignore | 4 + .vscode/settings.json | 3 + package-lock.json | 18 +++ package.json | 6 + tsconfig.json | 10 ++ ui.html | 323 +++++------------------------------------- ui.js | 290 +++++++++++++++++++++++++++++++++++++ 7 files changed, 367 insertions(+), 287 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 ui.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42e6c4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.vscode/c_cpp_properties.json +/.libs +/libapi.* +/node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1295ee6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..898839d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,18 @@ +{ + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@types/emscripten": { + "version": "1.39.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.5.tgz", + "integrity": "sha512-DIOOg+POSrYl+OlNRHQuIEqCd8DCtynG57H862UCce16nXJX7J8eWxNGgOcf8Eyge8zXeSs27mz1UcFu8L/L7g==", + "dev": true + }, + "preact": { + "version": "10.5.14", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.14.tgz", + "integrity": "sha512-KojoltCrshZ099ksUZ2OQKfbH66uquFoxHSbnwKbTJHeQNvx42EmC7wQVWNuDt6vC5s3nudRHFtKbpY4ijKlaQ==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7efb360 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@types/emscripten": "^1.39.5", + "preact": "^10.5.14" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..72d6922 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "checkJs": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "noEmit": true + }, + "exclude": ["libapi.*"] +} diff --git a/ui.html b/ui.html index 01f9d39..acb97f1 100644 --- a/ui.html +++ b/ui.html @@ -1,293 +1,42 @@ - - -
-
- -
-
-
- - + + + + +
+
+ +
+
+
+ + diff --git a/ui.js b/ui.js new file mode 100644 index 0000000..e97fe93 --- /dev/null +++ b/ui.js @@ -0,0 +1,290 @@ +import { h, render, Component } from 'preact'; +import initModule from './libapi.mjs'; + +/** @typedef {InstanceType} Context */ +/** @typedef {import('./libapi.mjs').Config} Config */ + +if (new URLSearchParams(location.search).has('debug')) { + // @ts-ignore + await import('preact/debug'); +} + +/** Schedules an exclusive async operation on the global context. */ +const scheduleOp = (() => { + let queue = initModule() + .then(Module => new Module.Context()) + .then(ctx => { + addEventListener('beforeunload', e => { + ctx.delete(); + }); + return ctx; + }); + + /** + * @template T + * @param {(ctx: Context) => Promise} op + * @returns {Promise} + */ + return function scheduleOp(op) { + let caughtRes = queue.then(async ctx => { + let resultPromise = op(ctx); + try { + await resultPromise; + } catch {} + return { + ctx, + resultPromise + }; + }); + + // Queue should ignore result values as well as errors from singular ops. + queue = caughtRes.then(res => res.ctx); + + // Result should contain the unwrapped value or error. + return caughtRes.then(res => res.resultPromise); + }; +})(); + +/** + * + * @param {{ + * config: Config, + * inProgress: boolean, + * }} params + * @returns {import('preact').VNode} + */ +function Config({ config, inProgress }) { + let { label, name } = config; + let id = `config-${name}`; + if (config.type === 'window' || config.type === 'section') { + return h( + 'fieldset', + { id }, + h('legend', {}, label), + Object.values(config.children).map(config => + h(Config, { key: config.name, config, inProgress }) + ) + ); + } + let { value, readonly } = config; + let valueProp; + switch (config.type) { + case 'toggle': + valueProp = 'checked'; + break; + case 'range': + case 'datetime': + valueProp = 'valueAsNumber'; + break; + default: + valueProp = 'value'; + } + // We don't want to override current input's value while user is editing it. + if (document.activeElement?.id === id) { + // @ts-ignore + value = document.activeElement?.[valueProp]; + } + let attrs = { + id, + [valueProp]: value, + readonly, + disabled: inProgress + }; + let inputElem; + switch (config.type) { + case 'range': { + let { min, max, step } = config; + inputElem = h('input', { type: 'number', min, max, step, ...attrs }); + break; + } + case 'text': + inputElem = readonly ? value : h('input', attrs); + break; + case 'toggle': { + inputElem = h('input', { + type: 'checkbox', + ...attrs + }); + break; + } + case 'menu': + case 'radio': { + let { choices } = config; + inputElem = h( + 'select', + attrs, + choices.map(choice => + h( + 'option', + { + key: choice, + value: choice, + disabled: attrs.readonly && value !== choice + }, + choice + ) + ) + ); + break; + } + case 'datetime': { + inputElem = h('input', { + type: 'datetime-local', + ...attrs + }); + break; + } + default: { + inputElem = '(unimplemented)'; + break; + } + } + return h( + 'div', + { class: 'pure-control-group' }, + h('label', { for: id }, label), + inputElem + ); +} + +class Settings extends Component { + state = { inProgress: false, config: 'Connecting...' }; + + constructor() { + super(); + (async () => { + while (true) { + await this.refreshConfig(); + await new Promise(resolve => setTimeout(resolve, 100)); + } + })(); + } + + handleChange = async e => { + let name = e.target.id; + if (!name.startsWith('config-')) { + throw new Error('Unhandled input'); + } + name = name.slice('config-'.length); + let value; + switch (e.target.type) { + case 'checkbox': + value = e.target.checked; + break; + case 'number': + case 'datetime-local': + value = e.target.valueAsNumber; + break; + default: + value = e.target.value; + break; + } + + this.setState({ inProgress: true }); + + try { + await scheduleOp(context => context.setConfigValue(name, value)); + } catch (e) { + console.error(e); + } + + await this.refreshConfig(); + this.setState({ inProgress: false }); + }; + + async refreshConfig() { + let config; + try { + config = await scheduleOp(context => context.configToJS()); + } catch (e) { + config = String(e); + } + this.setState({ + config + }); + } + + handleCapture = async () => { + let file = await scheduleOp(context => context.captureImageAsFile()); + let a = document.createElement('a'); + a.download = file.name; + a.href = URL.createObjectURL(file); + a.click(); + URL.revokeObjectURL(a.href); + }; + + render(props, state) { + return typeof state.config === 'string' + ? state.config + : h( + 'form', + { + class: 'pure-form pure-form-aligned', + onchange: this.handleChange + }, + h('input', { + type: 'button', + value: 'Capture image', + onclick: this.handleCapture + }), + h(Config, state) + ); + } +} + +render(h(Settings, null), document.getElementById('config')); + +(async () => { + // I have no idea why, but if we connect too soon, it just hangs... + await new Promise(resolve => setTimeout(resolve, 500)); + + let canvas = /** @type {HTMLCanvasElement} */ ( + document.getElementById('canvas') + ); + + let canvasCtx = canvas.getContext('bitmaprenderer'); + + let ratio = 0; + + while (true) { + try { + let blob = await scheduleOp(context => context.capturePreviewAsBlob()); + + // If ratio is known; decode resized image right away - it's a bit faster. + // If it isn't known, retrieve entire image to calculate ratio from its dimensions. + let img = await createImageBitmap( + blob, + ratio + ? { + resizeWidth: canvas.width, + resizeHeight: canvas.height + } + : {} + ); + if (!ratio) { + ratio = img.width / img.height; + let canvasHolder = document.getElementById('canvas-holder'); + + function updateCanvasSize() { + let width = canvasHolder.offsetWidth - 10; + let height = canvasHolder.offsetHeight; + + if (height * ratio > width) { + height = width / ratio; + } else { + width = height * ratio; + } + + Object.assign(canvas, { width, height }); + } + + updateCanvasSize(); + new ResizeObserver(updateCanvasSize).observe(canvasHolder); + } + canvasCtx.transferFromImageBitmap(img); + } catch (e) { + console.warn(e); + } + await new Promise(resolve => requestAnimationFrame(resolve)); + } +})();