diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8ed5d0dd9..bb9af8616a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,7 @@ repos: nicegui/static/fonts/.*| nicegui/static/fonts\.css| nicegui/static/lang/.*| + nicegui/static/nicegui\.js| nicegui/static/quasar\..*| nicegui/static/socket\..*| nicegui/static/tailwindcss\..*| @@ -48,3 +49,11 @@ repos: )$ additional_dependencies: - tomli; python_version<'3.11' + - repo: local + hooks: + - id: nicegui-js-build + name: Build nicegui.js from src + entry: bash -c 'cd nicegui/static && npm run build' + language: system + files: ^nicegui/static/src/.*\.js$ + pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 285f6109f9..6da8e59999 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -368,6 +368,49 @@ To update or add new dependencies, we follow these steps: 3. Run `npm run build` to copy the dependencies into the `nicegui/static/` directory or to bundle the dependencies in the `nicegui/elements/.../` directories. +### JavaScript Development for nicegui.js + +NiceGUI's core JavaScript is modularized in `nicegui/static/src/` and built into `nicegui/static/nicegui.js` using esbuild. + +**Setup:** + +```bash +cd nicegui/static +npm install +``` + +**Development workflow:** + +```bash +# Terminal 1: Watch and auto-rebuild +cd nicegui/static +npm run watch + +# Terminal 2: Run dev server +python main.py +``` + +**Module structure:** + +- `src/index.js` - Entry point and exports +- `src/constants.js` - Python-style constants +- `src/colors.js` - Color utilities +- `src/elements.js` - Element access and mounted app +- `src/events.js` - Event handling and throttling +- `src/render.js` - Vue rendering logic +- `src/utils.js` - General utilities +- `src/app.js` - Vue app creation and socket handlers +- `src/quasar-hack.js` - Quasar CSS fixes + +**Build system:** + +- Uses esbuild for fast bundling +- Outputs IIFE format with flattened window globals +- `build.mjs` - Build script with esbuild configuration +- Pre-commit hook automatically rebuilds on `src/` changes + +**Testing:** Run `pytest tests/` after JavaScript changes. + The following tools are used to update other resources: - fetch_google_fonts.py for fetching the Google Fonts diff --git a/nicegui.js b/nicegui.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nicegui/static/.gitignore b/nicegui/static/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/nicegui/static/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/nicegui/static/build.mjs b/nicegui/static/build.mjs new file mode 100644 index 0000000000..884c0d7881 --- /dev/null +++ b/nicegui/static/build.mjs @@ -0,0 +1,72 @@ +import * as esbuild from 'esbuild'; +import * as fs from 'fs'; + +const isWatch = process.argv.includes('--watch'); + +const config = { + entryPoints: ['./src/index.js'], + bundle: true, + outfile: './nicegui.js', + format: 'iife', + globalName: 'NiceGUI', + external: ['vue', 'quasar', 'socket.io'], + // Map external modules to their global variables + banner: { + js: `/* NiceGUI Client Bundle - Built with esbuild */`, + }, + footer: { + js: ` +// Flatten exports to window for backwards compatibility +if (typeof window !== "undefined") { + const exports = NiceGUI; + window.True = exports.True; + window.False = exports.False; + window.None = exports.None; + window.getElement = exports.getElement; + window.getHtmlElement = exports.getHtmlElement; + window.runMethod = exports.runMethod; + window.getComputedProp = exports.getComputedProp; + window.emitEvent = exports.emitEvent; + window.logAndEmit = exports.logAndEmit; + window.runJavascript = exports.runJavascript; + window.download = exports.download; + window.ack = exports.ack; + window.parseElements = exports.parseElements; + window.createApp = exports.createApp; + window.applyColors = exports.applyColors; + window.TAB_ID = exports.TAB_ID; + window.OLD_TAB_ID = exports.OLD_TAB_ID; + + // Expose app and mounted_app via getters + Object.defineProperty(window, "mounted_app", { + get: exports.getMountedApp, + enumerable: true, + configurable: true + }); + + Object.defineProperty(window, "app", { + get: exports.getApp, + enumerable: true, + configurable: true + }); +} +`, + }, + minify: false, + keepNames: true, + sourcemap: false, +}; + +if (isWatch) { + const context = await esbuild.context(config); + await context.watch(); + console.log('Watching for changes...'); +} else { + await esbuild.build(config); + // Add trailing newline for pre-commit hook + const content = fs.readFileSync('./nicegui.js', 'utf8'); + if (!content.endsWith('\n')) { + fs.appendFileSync('./nicegui.js', '\n'); + } + console.log('Build complete!'); +} diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js index 3986406315..bd02c53dfd 100644 --- a/nicegui/static/nicegui.js +++ b/nicegui/static/nicegui.js @@ -1,495 +1,581 @@ -const True = true; -const False = false; -const None = undefined; - -let app = undefined; -let mounted_app = undefined; - -function applyColors(colors) { - const quasarColors = ["primary", "secondary", "accent", "dark", "dark-page", "positive", "negative", "info", "warning"]; - let customCSS = ""; - for (let color in colors) { - if (quasarColors.includes(color)) - continue; - const colorName = color.replaceAll("_", "-"); - const colorVar = "--q-" + colorName; - document.body.style.setProperty(colorVar, colors[color]); - customCSS += `.text-${colorName} { color: var(${colorVar}) !important; }\n`; - customCSS += `.bg-${colorName} { background-color: var(${colorVar}) !important; }\n`; - } - if (!customCSS) return; - const style = document.createElement("style"); - style.innerHTML = customCSS; - style.dataset.niceguiCustomColors = ""; - document.head.querySelectorAll("[data-nicegui-custom-colors]").forEach((el) => el.remove()); - document.getElementsByTagName("head")[0].appendChild(style); -} - -function parseElements(raw_elements) { - return JSON.parse( - raw_elements - .replace(/$/g, "$") - .replace(/`/g, "`") - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/&/g, "&") - ); -} - -function replaceUndefinedAttributes(element) { - element.class ??= []; - element.style ??= {}; - element.props ??= {}; - element.text ??= null; - element.events ??= []; - element.update_method ??= null; - element.slots = { - default: { ids: element.children || [] }, - ...(element.slots ?? {}), +/* NiceGUI Client Bundle - Built with esbuild */ +var NiceGUI = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __name = (target2, value2) => __defProp(target2, "name", { value: value2, configurable: true }); + var __export = (target2, all) => { + for (var name in all) + __defProp(target2, name, { get: all[name], enumerable: true }); }; -} + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key2 of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key2) && key2 !== except) + __defProp(to, key2, { get: () => from[key2], enumerable: !(desc = __getOwnPropDesc(from, key2)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -function getElement(id) { - const _id = id instanceof Element ? id.id.slice(1) : id; - return mounted_app.$refs["r" + _id]; -} + // src/index.js + var index_exports = {}; + __export(index_exports, { + False: () => False, + None: () => None, + OLD_TAB_ID: () => OLD_TAB_ID, + TAB_ID: () => TAB_ID, + True: () => True, + ack: () => ack, + applyColors: () => applyColors, + createApp: () => createApp, + download: () => download, + emitEvent: () => emitEvent, + getApp: () => getApp, + getComputedProp: () => getComputedProp, + getElement: () => getElement, + getHtmlElement: () => getHtmlElement, + getMountedApp: () => getMountedApp, + logAndEmit: () => logAndEmit, + parseElements: () => parseElements, + replaceUndefinedAttributes: () => replaceUndefinedAttributes, + runJavascript: () => runJavascript, + runMethod: () => runMethod + }); -function getHtmlElement(id) { - let id_as_a_string = id.toString(); - if (!id_as_a_string.startsWith("c")) { - id_as_a_string = "c" + id_as_a_string; - } - return document.getElementById(id_as_a_string); -} + // src/constants.js + var True = true; + var False = false; + var None = void 0; -function runMethod(target, method_name, args) { - if (typeof target === "object") { - if (method_name in target) { - return target[method_name](...args); - } else { - return eval(method_name)(target, ...args); + // src/colors.js + function applyColors(colors) { + const quasarColors = ["primary", "secondary", "accent", "dark", "dark-page", "positive", "negative", "info", "warning"]; + let customCSS = ""; + for (let color in colors) { + if (quasarColors.includes(color)) + continue; + const colorName = color.replaceAll("_", "-"); + const colorVar = "--q-" + colorName; + document.body.style.setProperty(colorVar, colors[color]); + customCSS += `.text-${colorName} { color: var(${colorVar}) !important; } +`; + customCSS += `.bg-${colorName} { background-color: var(${colorVar}) !important; } +`; } + if (!customCSS) return; + const style = document.createElement("style"); + style.innerHTML = customCSS; + style.dataset.niceguiCustomColors = ""; + document.head.querySelectorAll("[data-nicegui-custom-colors]").forEach((el) => el.remove()); + document.getElementsByTagName("head")[0].appendChild(style); } - const element = getElement(target); - if (element === null || element === undefined) return; - if (method_name in element) { - return element[method_name](...args); - } else if (method_name in (element.$refs.qRef || [])) { - return element.$refs.qRef[method_name](...args); - } else { - return eval(method_name)(element, ...args); - } -} + __name(applyColors, "applyColors"); -function getComputedProp(target, prop_name) { - if (typeof target === "object" && prop_name in target) { - return target[prop_name]; + // src/elements.js + var mounted_app = void 0; + function setMountedApp(app3) { + mounted_app = app3; } - const element = getElement(target); - if (element === null || element === undefined) return; - if (prop_name in element) { - return element[prop_name]; - } else if (prop_name in (element.$refs.qRef || [])) { - return element.$refs.qRef[prop_name]; + __name(setMountedApp, "setMountedApp"); + function getMountedApp() { + return mounted_app; } -} - -function emitEvent(event_name, ...args) { - getElement(0).$emit(event_name, ...args); -} - -function logAndEmit(level, message) { - if (level === "error") { - console.error(message); - } else if (level === "warning") { - console.warn(message); - } else { - console.log(message); + __name(getMountedApp, "getMountedApp"); + function replaceUndefinedAttributes(element2) { + element2.class ??= []; + element2.style ??= {}; + element2.props ??= {}; + element2.text ??= null; + element2.events ??= []; + element2.update_method ??= null; + element2.slots = { + default: { ids: element2.children || [] }, + ...element2.slots ?? {} + }; } - window.socket.emit("log", { client_id: window.clientId, level, message }); -} + __name(replaceUndefinedAttributes, "replaceUndefinedAttributes"); + function getElement(id2) { + const _id = id2 instanceof Element ? id2.id.slice(1) : id2; + return mounted_app.$refs["r" + _id]; + } + __name(getElement, "getElement"); + function getHtmlElement(id2) { + let id_as_a_string = id2.toString(); + if (!id_as_a_string.startsWith("c")) { + id_as_a_string = "c" + id_as_a_string; + } + return document.getElementById(id_as_a_string); + } + __name(getHtmlElement, "getHtmlElement"); -function stringifyEventArgs(args, event_args) { - const result = []; - args.forEach((arg, i) => { - if (event_args !== null && i >= event_args.length) return; - let filtered = {}; - if (typeof arg !== "object" || arg === null || Array.isArray(arg)) { - filtered = arg; - } else { - for (let k in arg) { - // ignore "Restricted" fields in Firefox (see #2469) - if (k == "originalTarget") { - try { - arg[k].toString(); - } catch (e) { - continue; - } - } - if (event_args === null || event_args[i] === null || event_args[i].includes(k)) { - filtered[k] = arg[k]; - } + // src/utils.js + function parseElements(raw_elements) { + return JSON.parse( + raw_elements.replace(/$/g, "$").replace(/`/g, "`").replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&") + ); + } + __name(parseElements, "parseElements"); + function runMethod(target, method_name, args) { + if (typeof target === "object") { + if (method_name in target) { + return target[method_name](...args); + } else { + return eval(method_name)(target, ...args); } } - result.push(JSON.stringify(filtered, (k, v) => (v instanceof Node || v instanceof Window ? undefined : v))); - }); - return result; -} - -const waitingCallbacks = new Map(); -function throttle(callback, time, leading, trailing, id) { - if (time <= 0) { - // execute callback immediately and return - callback(); - return; + const element = getElement(target); + if (element === null || element === void 0) return; + if (method_name in element) { + return element[method_name](...args); + } else if (method_name in (element.$refs.qRef || [])) { + return element.$refs.qRef[method_name](...args); + } else { + return eval(method_name)(element, ...args); + } } - if (waitingCallbacks.has(id)) { - if (trailing) { - // update trailing callback - waitingCallbacks.set(id, callback); + __name(runMethod, "runMethod"); + function getComputedProp(target2, prop_name) { + if (typeof target2 === "object" && prop_name in target2) { + return target2[prop_name]; } - } else { - if (leading) { - // execute leading callback and set timeout to block more leading callbacks - callback(); - waitingCallbacks.set(id, null); - } else if (trailing) { - // set trailing callback and set timeout to execute it - waitingCallbacks.set(id, callback); + const element2 = getElement(target2); + if (element2 === null || element2 === void 0) return; + if (prop_name in element2) { + return element2[prop_name]; + } else if (prop_name in (element2.$refs.qRef || [])) { + return element2.$refs.qRef[prop_name]; } - if (leading || trailing) { - // set timeout to remove block and to execute trailing callback - setTimeout(() => { - const trailingCallback = waitingCallbacks.get(id); - if (trailingCallback) trailingCallback(); - waitingCallbacks.delete(id); - }, 1000 * time); + } + __name(getComputedProp, "getComputedProp"); + function emitEvent(event_name2, ...args2) { + getElement(0).$emit(event_name2, ...args2); + } + __name(emitEvent, "emitEvent"); + function logAndEmit(level, message) { + if (level === "error") { + console.error(message); + } else if (level === "warning") { + console.warn(message); + } else { + console.log(message); } + window.socket.emit("log", { client_id: window.clientId, level, message }); } -} -function renderRecursively(elements, id, propsContext) { - const element = elements[id]; - if (element === undefined) { - return; + __name(logAndEmit, "logAndEmit"); + function runJavascript(code, request_id) { + new Promise((resolve) => resolve(eval(code))).catch((reason) => { + if (reason instanceof SyntaxError) return eval(`(async() => {${code}})()`); + else throw reason; + }).then((result) => { + if (request_id) { + window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result }); + } + }); } - - const props = { - id: "c" + id, - ref: "r" + id, - key: id, // HACK: workaround for #600 and #898 - class: element.class.join(" ") || undefined, - style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, "") || undefined, - ...element.props, + __name(runJavascript, "runJavascript"); + function download(src, filename, mediaType, prefix) { + const anchor = document.createElement("a"); + if (typeof src === "string") { + anchor.href = src.startsWith("/") ? prefix + src : src; + } else { + anchor.href = URL.createObjectURL(new Blob([src], { type: mediaType })); + } + anchor.target = "_blank"; + anchor.download = filename || ""; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + if (typeof src !== "string") { + URL.revokeObjectURL(anchor.href); + } + } + __name(download, "download"); + function ack() { + if (!window.socket || !window.did_handshake) return; + if (window.ackedMessageId >= window.nextMessageId) return; + window.socket.emit("ack", { + client_id: window.clientId, + next_message_id: window.nextMessageId + }); + window.ackedMessageId = window.nextMessageId; + } + __name(ack, "ack"); + function createRandomUUID() { + try { + return crypto.randomUUID(); + } catch (e2) { + return "10000000-1000-4000-8000-100000000000".replace( + /[018]/g, + (c) => (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) + ); + } + } + __name(createRandomUUID, "createRandomUUID"); + var OLD_TAB_ID = sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id : null; + var TAB_ID = !sessionStorage.__nicegui_tab_id || sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id = createRandomUUID() : sessionStorage.__nicegui_tab_id; + sessionStorage.__nicegui_tab_closed = "false"; + window.onbeforeunload = function() { + sessionStorage.__nicegui_tab_closed = "true"; }; - Object.entries(props).forEach(([key, value]) => { - if (key.startsWith(":")) { - try { - try { - props[key.substring(1)] = new Function("props", `return (${value})`)(propsContext); - } catch (e) { - props[key.substring(1)] = eval(value); + + // src/events.js + function stringifyEventArgs(args2, event_args) { + const result = []; + args2.forEach((arg, i) => { + if (event_args !== null && i >= event_args.length) return; + let filtered = {}; + if (typeof arg !== "object" || arg === null || Array.isArray(arg)) { + filtered = arg; + } else { + for (let k in arg) { + if (k == "originalTarget") { + try { + arg[k].toString(); + } catch (e2) { + continue; + } + } + if (event_args === null || event_args[i] === null || event_args[i].includes(k)) { + filtered[k] = arg[k]; + } } - delete props[key]; - } catch (e) { - console.error(`Error while converting ${key} attribute to function:`, e); } + result.push(JSON.stringify(filtered, (k, v) => v instanceof Node || v instanceof Window ? void 0 : v)); + }); + return result; + } + __name(stringifyEventArgs, "stringifyEventArgs"); + var waitingCallbacks = /* @__PURE__ */ new Map(); + function throttle(callback, time, leading, trailing, id2) { + if (time <= 0) { + callback(); + return; } - }); - element.events.forEach((event) => { - let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1); - event.specials.forEach((s) => (event_name += s[0].toLocaleUpperCase() + s.substring(1))); - - const emit = (...args) => { - const emitter = () => - window.socket?.emit("event", { - id: id, - client_id: window.clientId, - listener_id: event.listener_id, - args: stringifyEventArgs(args, event.args), - }); - const delayed_emitter = () => { - if (window.did_handshake) emitter(); - else setTimeout(delayed_emitter, 10); - }; - throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id); - if (element.props["loopback"] === False && event.type == "update:modelValue") { - element.props["model-value"] = args; + if (waitingCallbacks.has(id2)) { + if (trailing) { + waitingCallbacks.set(id2, callback); } - }; - - let handler; - if (event.js_handler) { - const props = propsContext; // make `props` accessible from inside the event handler - handler = eval(event.js_handler); } else { - handler = emit; + if (leading) { + callback(); + waitingCallbacks.set(id2, null); + } else if (trailing) { + waitingCallbacks.set(id2, callback); + } + if (leading || trailing) { + setTimeout(() => { + const trailingCallback = waitingCallbacks.get(id2); + if (trailingCallback) trailingCallback(); + waitingCallbacks.delete(id2); + }, 1e3 * time); + } } + } + __name(throttle, "throttle"); - handler = Vue.withModifiers(handler, event.modifiers); - handler = event.keys.length ? Vue.withKeys(handler, event.keys) : handler; - if (props[event_name]) { - props[event_name].push(handler); - } else { - props[event_name] = [handler]; + // src/render.js + function renderRecursively(elements, id, propsContext) { + const element = elements[id]; + if (element === void 0) { + return; } - }); - const slots = {}; - const element_slots = { - default: { ids: element.children || [] }, - ...element.slots, - }; - Object.entries(element_slots).forEach(([name, data]) => { - slots[name] = (props) => { - const rendered = []; - if (data.template) { - rendered.push( - Vue.h( - { - props: { props: { type: Object, default: {} } }, - template: data.template, - }, - { - props: props, - } - ) - ); + const props = { + id: "c" + id, + ref: "r" + id, + key: id, + // HACK: workaround for #600 and #898 + class: element.class.join(" ") || void 0, + style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, "") || void 0, + ...element.props + }; + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith(":")) { + try { + try { + props[key.substring(1)] = new Function("props", `return (${value})`)(propsContext); + } catch (e) { + props[key.substring(1)] = eval(value); + } + delete props[key]; + } catch (e2) { + console.error(`Error while converting ${key} attribute to function:`, e2); + } } - const children = data.ids.map((id) => renderRecursively(elements, id, props || propsContext)); - if (name === "default" && element.text !== null) { - children.unshift(element.text); + }); + element.events.forEach((event) => { + let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1); + event.specials.forEach((s) => event_name += s[0].toLocaleUpperCase() + s.substring(1)); + const emit = /* @__PURE__ */ __name((...args2) => { + const emitter = /* @__PURE__ */ __name(() => window.socket?.emit("event", { + id, + client_id: window.clientId, + listener_id: event.listener_id, + args: stringifyEventArgs(args2, event.args) + }), "emitter"); + const delayed_emitter = /* @__PURE__ */ __name(() => { + if (window.did_handshake) emitter(); + else setTimeout(delayed_emitter, 10); + }, "delayed_emitter"); + throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id); + if (element.props["loopback"] === False && event.type == "update:modelValue") { + element.props["model-value"] = args2; + } + }, "emit"); + let handler; + if (event.js_handler) { + const props = propsContext; + handler = eval(event.js_handler); + } else { + handler = emit; } - return [...rendered, ...children]; - }; - }); - return Vue.h(app.config.isNativeTag(element.tag) ? element.tag : Vue.resolveComponent(element.tag), props, slots); -} - -function runJavascript(code, request_id) { - new Promise((resolve) => resolve(eval(code))) - .catch((reason) => { - if (reason instanceof SyntaxError) return eval(`(async() => {${code}})()`); - else throw reason; - }) - .then((result) => { - if (request_id) { - window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result }); + handler = Vue.withModifiers(handler, event.modifiers); + handler = event.keys.length ? Vue.withKeys(handler, event.keys) : handler; + if (props[event_name]) { + props[event_name].push(handler); + } else { + props[event_name] = [handler]; } }); -} - -function download(src, filename, mediaType, prefix) { - const anchor = document.createElement("a"); - if (typeof src === "string") { - anchor.href = src.startsWith("/") ? prefix + src : src; - } else { - anchor.href = URL.createObjectURL(new Blob([src], { type: mediaType })); - } - anchor.target = "_blank"; - anchor.download = filename || ""; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - if (typeof src !== "string") { - URL.revokeObjectURL(anchor.href); + const slots = {}; + const element_slots = { + default: { ids: element.children || [] }, + ...element.slots + }; + Object.entries(element_slots).forEach(([name, data]) => { + slots[name] = (props2) => { + const rendered = []; + if (data.template) { + rendered.push( + Vue.h( + { + props: { props: { type: Object, default: {} } }, + template: data.template + }, + { + props: props2 + } + ) + ); + } + const children = data.ids.map((id2) => renderRecursively(elements, id2, props2 || propsContext)); + if (name === "default" && element.text !== null) { + children.unshift(element.text); + } + return [...rendered, ...children]; + }; + }); + const app = getApp(); + return Vue.h(app.config.isNativeTag(element.tag) ? element.tag : Vue.resolveComponent(element.tag), props, slots); } -} + __name(renderRecursively, "renderRecursively"); -function ack() { - if (!window.socket || !window.did_handshake) return; - if (window.ackedMessageId >= window.nextMessageId) return; - window.socket.emit("ack", { - client_id: window.clientId, - next_message_id: window.nextMessageId, - }); - window.ackedMessageId = window.nextMessageId; -} - -function createRandomUUID() { - try { - return crypto.randomUUID(); - } catch (e) { - // https://stackoverflow.com/a/2117523/3419103 - return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => - (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16) - ); + // src/app.js + var app2 = void 0; + function getApp() { + return app2; } -} - -const OLD_TAB_ID = sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id : null; -const TAB_ID = - !sessionStorage.__nicegui_tab_id || sessionStorage.__nicegui_tab_closed === "false" - ? (sessionStorage.__nicegui_tab_id = createRandomUUID()) - : sessionStorage.__nicegui_tab_id; -sessionStorage.__nicegui_tab_closed = "false"; -window.onbeforeunload = function () { - sessionStorage.__nicegui_tab_closed = "true"; -}; - -function createApp(elements, options) { - Object.entries(elements).forEach(([_, element]) => replaceUndefinedAttributes(element)); - setInterval(() => ack(), 3000); - return (app = Vue.createApp({ - data() { - return { - elements, - }; - }, - render() { - return renderRecursively(this.elements, 0); - }, - mounted() { - mounted_app = this; - window.documentId = createRandomUUID(); - window.clientId = options.query.client_id; - const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host; - window.path_prefix = options.prefix; - window.nextMessageId = options.query.next_message_id; - window.ackedMessageId = -1; - window.socket = io(url, { - path: `${options.prefix}/_nicegui_ws/socket.io`, - query: options.query, - extraHeaders: options.extraHeaders, - transports: - "prerendering" in document && document.prerendering === true - ? ["polling", ...options.transports] - : options.transports, - }); - window.did_handshake = false; - const messageHandlers = { - connect: () => { - function wrapFunction(originalFunction) { - const MAX_WEBSOCKET_MESSAGE_SIZE = 1000000 - 100; // 1MB without 100 bytes of slack for the message header - return function (...args) { - const msg = args[0]; - if (typeof msg === "string" && msg.length > MAX_WEBSOCKET_MESSAGE_SIZE) { - const errorMessage = `Payload size ${msg.length} exceeds the maximum allowed limit.`; - console.error(errorMessage); - args[0] = `42["log",{"client_id":"${window.clientId}","level":"error","message":"${errorMessage}"}]`; - if (window.tooLongMessageTimerId) clearTimeout(window.tooLongMessageTimerId); - const popup = document.getElementById("too_long_message_popup"); - popup.ariaHidden = false; - window.tooLongMessageTimerId = setTimeout(() => (popup.ariaHidden = true), 5000); - } - return originalFunction.call(this, ...args); + __name(getApp, "getApp"); + function createApp(elements2, options) { + Object.entries(elements2).forEach(([_, element2]) => replaceUndefinedAttributes(element2)); + setInterval(() => ack(), 3e3); + return app2 = Vue.createApp({ + data() { + return { + elements: elements2 + }; + }, + render() { + return renderRecursively(this.elements, 0); + }, + mounted() { + setMountedApp(this); + window.documentId = createRandomUUID(); + window.clientId = options.query.client_id; + const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host; + window.path_prefix = options.prefix; + window.nextMessageId = options.query.next_message_id; + window.ackedMessageId = -1; + window.socket = io(url, { + path: `${options.prefix}/_nicegui_ws/socket.io`, + query: options.query, + extraHeaders: options.extraHeaders, + transports: "prerendering" in document && document.prerendering === true ? ["polling", ...options.transports] : options.transports + }); + window.did_handshake = false; + const messageHandlers = { + connect: /* @__PURE__ */ __name(() => { + function wrapFunction(originalFunction) { + const MAX_WEBSOCKET_MESSAGE_SIZE = 1e6 - 100; + return function(...args3) { + const msg = args3[0]; + if (typeof msg === "string" && msg.length > MAX_WEBSOCKET_MESSAGE_SIZE) { + const errorMessage = `Payload size ${msg.length} exceeds the maximum allowed limit.`; + console.error(errorMessage); + args3[0] = `42["log",{"client_id":"${window.clientId}","level":"error","message":"${errorMessage}"}]`; + if (window.tooLongMessageTimerId) clearTimeout(window.tooLongMessageTimerId); + const popup = document.getElementById("too_long_message_popup"); + popup.ariaHidden = false; + window.tooLongMessageTimerId = setTimeout(() => popup.ariaHidden = true, 5e3); + } + return originalFunction.call(this, ...args3); + }; + } + __name(wrapFunction, "wrapFunction"); + const transport = window.socket.io.engine.transport; + if (transport?.ws?.send) transport.ws.send = wrapFunction(transport.ws.send); + if (transport?.doWrite) transport.doWrite = wrapFunction(transport.doWrite); + const args2 = { + client_id: window.clientId, + document_id: window.documentId, + tab_id: TAB_ID, + old_tab_id: OLD_TAB_ID, + next_message_id: window.nextMessageId }; - } - const transport = window.socket.io.engine.transport; - if (transport?.ws?.send) transport.ws.send = wrapFunction(transport.ws.send); - if (transport?.doWrite) transport.doWrite = wrapFunction(transport.doWrite); - - const args = { - client_id: window.clientId, - document_id: window.documentId, - tab_id: TAB_ID, - old_tab_id: OLD_TAB_ID, - next_message_id: window.nextMessageId, - }; - window.socket.emit("handshake", args, (ok) => { - if (!ok) { - console.log("reloading because handshake failed for clientId " + window.clientId); + window.socket.emit("handshake", args2, (ok) => { + if (!ok) { + console.log("reloading because handshake failed for clientId " + window.clientId); + window.location.reload(); + } + window.did_handshake = true; + document.getElementById("popup").ariaHidden = true; + }); + }, "connect"), + connect_error: /* @__PURE__ */ __name((err) => { + if (err.message == "timeout") { + console.log("reloading because connection timed out"); window.location.reload(); } - window.did_handshake = true; - document.getElementById("popup").ariaHidden = true; - }); - }, - connect_error: (err) => { - if (err.message == "timeout") { - console.log("reloading because connection timed out"); - window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198 - } - }, - try_reconnect: async () => { - document.getElementById("popup").ariaHidden = false; - await fetch(window.location.href, { headers: { "NiceGUI-Check": "try_reconnect" } }); - console.log("reloading because reconnect was requested"); - window.location.reload(); - }, - disconnect: () => { - document.getElementById("popup").ariaHidden = false; - }, - load_js_components: async (msg) => { - const urls = msg.components.map((c) => `${options.prefix}/_nicegui/${options.version}/components/${c.key}`); - const imports = await Promise.all(urls.map((url) => import(url))); - msg.components.forEach((c, i) => app.component(c.tag, imports[i].default)); - }, - update: async (msg) => { - let eventListenersChanged = false; - for (const [id, element] of Object.entries(msg)) { - if (element === null) continue; - if (!(id in this.elements)) continue; - const oldListenerIds = new Set((this.elements[id]?.events || []).map((ev) => ev.listener_id)); - if (element.events?.some((e) => !oldListenerIds.has(e.listener_id))) { - delete this.elements[id]; - eventListenersChanged = true; + }, "connect_error"), + try_reconnect: /* @__PURE__ */ __name(async () => { + document.getElementById("popup").ariaHidden = false; + await fetch(window.location.href, { headers: { "NiceGUI-Check": "try_reconnect" } }); + console.log("reloading because reconnect was requested"); + window.location.reload(); + }, "try_reconnect"), + disconnect: /* @__PURE__ */ __name(() => { + document.getElementById("popup").ariaHidden = false; + }, "disconnect"), + load_js_components: /* @__PURE__ */ __name(async (msg) => { + const urls = msg.components.map((c) => `${options.prefix}/_nicegui/${options.version}/components/${c.key}`); + const imports = await Promise.all(urls.map((url2) => import(url2))); + msg.components.forEach((c, i) => app2.component(c.tag, imports[i].default)); + }, "load_js_components"), + update: /* @__PURE__ */ __name(async (msg) => { + let eventListenersChanged = false; + for (const [id2, element2] of Object.entries(msg)) { + if (element2 === null) continue; + if (!(id2 in this.elements)) continue; + const oldListenerIds = new Set((this.elements[id2]?.events || []).map((ev) => ev.listener_id)); + if (element2.events?.some((e2) => !oldListenerIds.has(e2.listener_id))) { + delete this.elements[id2]; + eventListenersChanged = true; + } + } + if (eventListenersChanged) { + logAndEmit("warning", "Event listeners changed after initial definition. Re-rendering affected elements."); + await this.$nextTick(); + } + for (const [id2, element2] of Object.entries(msg)) { + if (element2 === null) { + delete this.elements[id2]; + continue; + } + replaceUndefinedAttributes(element2); + this.elements[id2] = element2; } - } - if (eventListenersChanged) { - logAndEmit("warning", "Event listeners changed after initial definition. Re-rendering affected elements."); await this.$nextTick(); - } - - for (const [id, element] of Object.entries(msg)) { - if (element === null) { - delete this.elements[id]; - continue; + for (const [id2, element2] of Object.entries(msg)) { + if (element2?.update_method) { + getElement(id2)?.[element2.update_method](); + } } - replaceUndefinedAttributes(element); - this.elements[id] = element; - } - - await this.$nextTick(); - for (const [id, element] of Object.entries(msg)) { - if (element?.update_method) { - getElement(id)?.[element.update_method](); + }, "update"), + run_javascript: /* @__PURE__ */ __name((msg) => runJavascript(msg.code, msg.request_id), "run_javascript"), + open: /* @__PURE__ */ __name((msg) => { + const url2 = msg.path.startsWith("/") ? options.prefix + msg.path : msg.path; + const target2 = msg.new_tab ? "_blank" : "_self"; + window.open(url2, target2); + }, "open"), + download: /* @__PURE__ */ __name((msg) => download(msg.src, msg.filename, msg.media_type, options.prefix), "download"), + notify: /* @__PURE__ */ __name((msg) => Quasar.Notify.create(msg), "notify") + }; + const socketMessageQueue = []; + let isProcessingSocketMessage = false; + for (const [event2, handler2] of Object.entries(messageHandlers)) { + window.socket.on(event2, async (...args2) => { + if (args2.length > 0 && args2[0]._id !== void 0) { + const message_id = args2[0]._id; + if (message_id < window.nextMessageId) return; + window.nextMessageId = message_id + 1; + delete args2[0]._id; } - } - }, - run_javascript: (msg) => runJavascript(msg.code, msg.request_id), - open: (msg) => { - const url = msg.path.startsWith("/") ? options.prefix + msg.path : msg.path; - const target = msg.new_tab ? "_blank" : "_self"; - window.open(url, target); - }, - download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix), - notify: (msg) => Quasar.Notify.create(msg), - }; - const socketMessageQueue = []; - let isProcessingSocketMessage = false; - for (const [event, handler] of Object.entries(messageHandlers)) { - window.socket.on(event, async (...args) => { - if (args.length > 0 && args[0]._id !== undefined) { - const message_id = args[0]._id; - if (message_id < window.nextMessageId) return; - window.nextMessageId = message_id + 1; - delete args[0]._id; - } - socketMessageQueue.push(() => handler(...args)); - if (!isProcessingSocketMessage) { - while (socketMessageQueue.length > 0) { - const handler = socketMessageQueue.shift(); - isProcessingSocketMessage = true; - try { - await handler(); - } catch (e) { - console.error(e); + socketMessageQueue.push(() => handler2(...args2)); + if (!isProcessingSocketMessage) { + while (socketMessageQueue.length > 0) { + const handler3 = socketMessageQueue.shift(); + isProcessingSocketMessage = true; + try { + await handler3(); + } catch (e2) { + console.error(e2); + } + isProcessingSocketMessage = false; } - isProcessingSocketMessage = false; } - } - }); + }); + } } - }, - })); -} + }); + } + __name(createApp, "createApp"); -// HACK: remove Quasar's rules for divs in QCard (#2265, #2301) -for (const importRule of document.styleSheets[0].cssRules) { - if (importRule instanceof CSSImportRule && /quasar/.test(importRule.styleSheet?.href)) { - for (const rule of Array.from(importRule.styleSheet.cssRules)) { - if (rule instanceof CSSStyleRule && /\.q-card > div/.test(rule.selectorText)) { - if (/\.q-card > div/.test(rule.selectorText)) rule.selectorText = ".nicegui-card-tight" + rule.selectorText; + // src/quasar-hack.js + for (const importRule of document.styleSheets[0].cssRules) { + if (importRule instanceof CSSImportRule && /quasar/.test(importRule.styleSheet?.href)) { + for (const rule of Array.from(importRule.styleSheet.cssRules)) { + if (rule instanceof CSSStyleRule && /\.q-card > div/.test(rule.selectorText)) { + if (/\.q-card > div/.test(rule.selectorText)) rule.selectorText = ".nicegui-card-tight" + rule.selectorText; + } } } } + return __toCommonJS(index_exports); +})(); + +// Flatten exports to window for backwards compatibility +if (typeof window !== "undefined") { + const exports = NiceGUI; + window.True = exports.True; + window.False = exports.False; + window.None = exports.None; + window.getElement = exports.getElement; + window.getHtmlElement = exports.getHtmlElement; + window.runMethod = exports.runMethod; + window.getComputedProp = exports.getComputedProp; + window.emitEvent = exports.emitEvent; + window.logAndEmit = exports.logAndEmit; + window.runJavascript = exports.runJavascript; + window.download = exports.download; + window.ack = exports.ack; + window.parseElements = exports.parseElements; + window.createApp = exports.createApp; + window.applyColors = exports.applyColors; + window.TAB_ID = exports.TAB_ID; + window.OLD_TAB_ID = exports.OLD_TAB_ID; + + // Expose app and mounted_app via getters + Object.defineProperty(window, "mounted_app", { + get: exports.getMountedApp, + enumerable: true, + configurable: true + }); + + Object.defineProperty(window, "app", { + get: exports.getApp, + enumerable: true, + configurable: true + }); } + diff --git a/nicegui/static/nicegui.old.js b/nicegui/static/nicegui.old.js new file mode 100644 index 0000000000..3986406315 --- /dev/null +++ b/nicegui/static/nicegui.old.js @@ -0,0 +1,495 @@ +const True = true; +const False = false; +const None = undefined; + +let app = undefined; +let mounted_app = undefined; + +function applyColors(colors) { + const quasarColors = ["primary", "secondary", "accent", "dark", "dark-page", "positive", "negative", "info", "warning"]; + let customCSS = ""; + for (let color in colors) { + if (quasarColors.includes(color)) + continue; + const colorName = color.replaceAll("_", "-"); + const colorVar = "--q-" + colorName; + document.body.style.setProperty(colorVar, colors[color]); + customCSS += `.text-${colorName} { color: var(${colorVar}) !important; }\n`; + customCSS += `.bg-${colorName} { background-color: var(${colorVar}) !important; }\n`; + } + if (!customCSS) return; + const style = document.createElement("style"); + style.innerHTML = customCSS; + style.dataset.niceguiCustomColors = ""; + document.head.querySelectorAll("[data-nicegui-custom-colors]").forEach((el) => el.remove()); + document.getElementsByTagName("head")[0].appendChild(style); +} + +function parseElements(raw_elements) { + return JSON.parse( + raw_elements + .replace(/$/g, "$") + .replace(/`/g, "`") + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/&/g, "&") + ); +} + +function replaceUndefinedAttributes(element) { + element.class ??= []; + element.style ??= {}; + element.props ??= {}; + element.text ??= null; + element.events ??= []; + element.update_method ??= null; + element.slots = { + default: { ids: element.children || [] }, + ...(element.slots ?? {}), + }; +} + +function getElement(id) { + const _id = id instanceof Element ? id.id.slice(1) : id; + return mounted_app.$refs["r" + _id]; +} + +function getHtmlElement(id) { + let id_as_a_string = id.toString(); + if (!id_as_a_string.startsWith("c")) { + id_as_a_string = "c" + id_as_a_string; + } + return document.getElementById(id_as_a_string); +} + +function runMethod(target, method_name, args) { + if (typeof target === "object") { + if (method_name in target) { + return target[method_name](...args); + } else { + return eval(method_name)(target, ...args); + } + } + const element = getElement(target); + if (element === null || element === undefined) return; + if (method_name in element) { + return element[method_name](...args); + } else if (method_name in (element.$refs.qRef || [])) { + return element.$refs.qRef[method_name](...args); + } else { + return eval(method_name)(element, ...args); + } +} + +function getComputedProp(target, prop_name) { + if (typeof target === "object" && prop_name in target) { + return target[prop_name]; + } + const element = getElement(target); + if (element === null || element === undefined) return; + if (prop_name in element) { + return element[prop_name]; + } else if (prop_name in (element.$refs.qRef || [])) { + return element.$refs.qRef[prop_name]; + } +} + +function emitEvent(event_name, ...args) { + getElement(0).$emit(event_name, ...args); +} + +function logAndEmit(level, message) { + if (level === "error") { + console.error(message); + } else if (level === "warning") { + console.warn(message); + } else { + console.log(message); + } + window.socket.emit("log", { client_id: window.clientId, level, message }); +} + +function stringifyEventArgs(args, event_args) { + const result = []; + args.forEach((arg, i) => { + if (event_args !== null && i >= event_args.length) return; + let filtered = {}; + if (typeof arg !== "object" || arg === null || Array.isArray(arg)) { + filtered = arg; + } else { + for (let k in arg) { + // ignore "Restricted" fields in Firefox (see #2469) + if (k == "originalTarget") { + try { + arg[k].toString(); + } catch (e) { + continue; + } + } + if (event_args === null || event_args[i] === null || event_args[i].includes(k)) { + filtered[k] = arg[k]; + } + } + } + result.push(JSON.stringify(filtered, (k, v) => (v instanceof Node || v instanceof Window ? undefined : v))); + }); + return result; +} + +const waitingCallbacks = new Map(); +function throttle(callback, time, leading, trailing, id) { + if (time <= 0) { + // execute callback immediately and return + callback(); + return; + } + if (waitingCallbacks.has(id)) { + if (trailing) { + // update trailing callback + waitingCallbacks.set(id, callback); + } + } else { + if (leading) { + // execute leading callback and set timeout to block more leading callbacks + callback(); + waitingCallbacks.set(id, null); + } else if (trailing) { + // set trailing callback and set timeout to execute it + waitingCallbacks.set(id, callback); + } + if (leading || trailing) { + // set timeout to remove block and to execute trailing callback + setTimeout(() => { + const trailingCallback = waitingCallbacks.get(id); + if (trailingCallback) trailingCallback(); + waitingCallbacks.delete(id); + }, 1000 * time); + } + } +} +function renderRecursively(elements, id, propsContext) { + const element = elements[id]; + if (element === undefined) { + return; + } + + const props = { + id: "c" + id, + ref: "r" + id, + key: id, // HACK: workaround for #600 and #898 + class: element.class.join(" ") || undefined, + style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, "") || undefined, + ...element.props, + }; + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith(":")) { + try { + try { + props[key.substring(1)] = new Function("props", `return (${value})`)(propsContext); + } catch (e) { + props[key.substring(1)] = eval(value); + } + delete props[key]; + } catch (e) { + console.error(`Error while converting ${key} attribute to function:`, e); + } + } + }); + element.events.forEach((event) => { + let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1); + event.specials.forEach((s) => (event_name += s[0].toLocaleUpperCase() + s.substring(1))); + + const emit = (...args) => { + const emitter = () => + window.socket?.emit("event", { + id: id, + client_id: window.clientId, + listener_id: event.listener_id, + args: stringifyEventArgs(args, event.args), + }); + const delayed_emitter = () => { + if (window.did_handshake) emitter(); + else setTimeout(delayed_emitter, 10); + }; + throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id); + if (element.props["loopback"] === False && event.type == "update:modelValue") { + element.props["model-value"] = args; + } + }; + + let handler; + if (event.js_handler) { + const props = propsContext; // make `props` accessible from inside the event handler + handler = eval(event.js_handler); + } else { + handler = emit; + } + + handler = Vue.withModifiers(handler, event.modifiers); + handler = event.keys.length ? Vue.withKeys(handler, event.keys) : handler; + if (props[event_name]) { + props[event_name].push(handler); + } else { + props[event_name] = [handler]; + } + }); + const slots = {}; + const element_slots = { + default: { ids: element.children || [] }, + ...element.slots, + }; + Object.entries(element_slots).forEach(([name, data]) => { + slots[name] = (props) => { + const rendered = []; + if (data.template) { + rendered.push( + Vue.h( + { + props: { props: { type: Object, default: {} } }, + template: data.template, + }, + { + props: props, + } + ) + ); + } + const children = data.ids.map((id) => renderRecursively(elements, id, props || propsContext)); + if (name === "default" && element.text !== null) { + children.unshift(element.text); + } + return [...rendered, ...children]; + }; + }); + return Vue.h(app.config.isNativeTag(element.tag) ? element.tag : Vue.resolveComponent(element.tag), props, slots); +} + +function runJavascript(code, request_id) { + new Promise((resolve) => resolve(eval(code))) + .catch((reason) => { + if (reason instanceof SyntaxError) return eval(`(async() => {${code}})()`); + else throw reason; + }) + .then((result) => { + if (request_id) { + window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result }); + } + }); +} + +function download(src, filename, mediaType, prefix) { + const anchor = document.createElement("a"); + if (typeof src === "string") { + anchor.href = src.startsWith("/") ? prefix + src : src; + } else { + anchor.href = URL.createObjectURL(new Blob([src], { type: mediaType })); + } + anchor.target = "_blank"; + anchor.download = filename || ""; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + if (typeof src !== "string") { + URL.revokeObjectURL(anchor.href); + } +} + +function ack() { + if (!window.socket || !window.did_handshake) return; + if (window.ackedMessageId >= window.nextMessageId) return; + window.socket.emit("ack", { + client_id: window.clientId, + next_message_id: window.nextMessageId, + }); + window.ackedMessageId = window.nextMessageId; +} + +function createRandomUUID() { + try { + return crypto.randomUUID(); + } catch (e) { + // https://stackoverflow.com/a/2117523/3419103 + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16) + ); + } +} + +const OLD_TAB_ID = sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id : null; +const TAB_ID = + !sessionStorage.__nicegui_tab_id || sessionStorage.__nicegui_tab_closed === "false" + ? (sessionStorage.__nicegui_tab_id = createRandomUUID()) + : sessionStorage.__nicegui_tab_id; +sessionStorage.__nicegui_tab_closed = "false"; +window.onbeforeunload = function () { + sessionStorage.__nicegui_tab_closed = "true"; +}; + +function createApp(elements, options) { + Object.entries(elements).forEach(([_, element]) => replaceUndefinedAttributes(element)); + setInterval(() => ack(), 3000); + return (app = Vue.createApp({ + data() { + return { + elements, + }; + }, + render() { + return renderRecursively(this.elements, 0); + }, + mounted() { + mounted_app = this; + window.documentId = createRandomUUID(); + window.clientId = options.query.client_id; + const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host; + window.path_prefix = options.prefix; + window.nextMessageId = options.query.next_message_id; + window.ackedMessageId = -1; + window.socket = io(url, { + path: `${options.prefix}/_nicegui_ws/socket.io`, + query: options.query, + extraHeaders: options.extraHeaders, + transports: + "prerendering" in document && document.prerendering === true + ? ["polling", ...options.transports] + : options.transports, + }); + window.did_handshake = false; + const messageHandlers = { + connect: () => { + function wrapFunction(originalFunction) { + const MAX_WEBSOCKET_MESSAGE_SIZE = 1000000 - 100; // 1MB without 100 bytes of slack for the message header + return function (...args) { + const msg = args[0]; + if (typeof msg === "string" && msg.length > MAX_WEBSOCKET_MESSAGE_SIZE) { + const errorMessage = `Payload size ${msg.length} exceeds the maximum allowed limit.`; + console.error(errorMessage); + args[0] = `42["log",{"client_id":"${window.clientId}","level":"error","message":"${errorMessage}"}]`; + if (window.tooLongMessageTimerId) clearTimeout(window.tooLongMessageTimerId); + const popup = document.getElementById("too_long_message_popup"); + popup.ariaHidden = false; + window.tooLongMessageTimerId = setTimeout(() => (popup.ariaHidden = true), 5000); + } + return originalFunction.call(this, ...args); + }; + } + const transport = window.socket.io.engine.transport; + if (transport?.ws?.send) transport.ws.send = wrapFunction(transport.ws.send); + if (transport?.doWrite) transport.doWrite = wrapFunction(transport.doWrite); + + const args = { + client_id: window.clientId, + document_id: window.documentId, + tab_id: TAB_ID, + old_tab_id: OLD_TAB_ID, + next_message_id: window.nextMessageId, + }; + window.socket.emit("handshake", args, (ok) => { + if (!ok) { + console.log("reloading because handshake failed for clientId " + window.clientId); + window.location.reload(); + } + window.did_handshake = true; + document.getElementById("popup").ariaHidden = true; + }); + }, + connect_error: (err) => { + if (err.message == "timeout") { + console.log("reloading because connection timed out"); + window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198 + } + }, + try_reconnect: async () => { + document.getElementById("popup").ariaHidden = false; + await fetch(window.location.href, { headers: { "NiceGUI-Check": "try_reconnect" } }); + console.log("reloading because reconnect was requested"); + window.location.reload(); + }, + disconnect: () => { + document.getElementById("popup").ariaHidden = false; + }, + load_js_components: async (msg) => { + const urls = msg.components.map((c) => `${options.prefix}/_nicegui/${options.version}/components/${c.key}`); + const imports = await Promise.all(urls.map((url) => import(url))); + msg.components.forEach((c, i) => app.component(c.tag, imports[i].default)); + }, + update: async (msg) => { + let eventListenersChanged = false; + for (const [id, element] of Object.entries(msg)) { + if (element === null) continue; + if (!(id in this.elements)) continue; + const oldListenerIds = new Set((this.elements[id]?.events || []).map((ev) => ev.listener_id)); + if (element.events?.some((e) => !oldListenerIds.has(e.listener_id))) { + delete this.elements[id]; + eventListenersChanged = true; + } + } + if (eventListenersChanged) { + logAndEmit("warning", "Event listeners changed after initial definition. Re-rendering affected elements."); + await this.$nextTick(); + } + + for (const [id, element] of Object.entries(msg)) { + if (element === null) { + delete this.elements[id]; + continue; + } + replaceUndefinedAttributes(element); + this.elements[id] = element; + } + + await this.$nextTick(); + for (const [id, element] of Object.entries(msg)) { + if (element?.update_method) { + getElement(id)?.[element.update_method](); + } + } + }, + run_javascript: (msg) => runJavascript(msg.code, msg.request_id), + open: (msg) => { + const url = msg.path.startsWith("/") ? options.prefix + msg.path : msg.path; + const target = msg.new_tab ? "_blank" : "_self"; + window.open(url, target); + }, + download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix), + notify: (msg) => Quasar.Notify.create(msg), + }; + const socketMessageQueue = []; + let isProcessingSocketMessage = false; + for (const [event, handler] of Object.entries(messageHandlers)) { + window.socket.on(event, async (...args) => { + if (args.length > 0 && args[0]._id !== undefined) { + const message_id = args[0]._id; + if (message_id < window.nextMessageId) return; + window.nextMessageId = message_id + 1; + delete args[0]._id; + } + socketMessageQueue.push(() => handler(...args)); + if (!isProcessingSocketMessage) { + while (socketMessageQueue.length > 0) { + const handler = socketMessageQueue.shift(); + isProcessingSocketMessage = true; + try { + await handler(); + } catch (e) { + console.error(e); + } + isProcessingSocketMessage = false; + } + } + }); + } + }, + })); +} + +// HACK: remove Quasar's rules for divs in QCard (#2265, #2301) +for (const importRule of document.styleSheets[0].cssRules) { + if (importRule instanceof CSSImportRule && /quasar/.test(importRule.styleSheet?.href)) { + for (const rule of Array.from(importRule.styleSheet.cssRules)) { + if (rule instanceof CSSStyleRule && /\.q-card > div/.test(rule.selectorText)) { + if (/\.q-card > div/.test(rule.selectorText)) rule.selectorText = ".nicegui-card-tight" + rule.selectorText; + } + } + } +} diff --git a/nicegui/static/package-lock.json b/nicegui/static/package-lock.json new file mode 100644 index 0000000000..f3c903af08 --- /dev/null +++ b/nicegui/static/package-lock.json @@ -0,0 +1,479 @@ +{ + "name": "nicegui-static", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nicegui-static", + "devDependencies": { + "esbuild": "^0.24.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + } + } +} diff --git a/nicegui/static/package.json b/nicegui/static/package.json new file mode 100644 index 0000000000..4a49bd2ad6 --- /dev/null +++ b/nicegui/static/package.json @@ -0,0 +1,12 @@ +{ + "name": "nicegui-static", + "private": true, + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "clean": "rm -f nicegui.js" + }, + "devDependencies": { + "esbuild": "^0.24.2" + } +} diff --git a/nicegui/static/src/app.js b/nicegui/static/src/app.js new file mode 100644 index 0000000000..49583ab575 --- /dev/null +++ b/nicegui/static/src/app.js @@ -0,0 +1,170 @@ +import { replaceUndefinedAttributes, setMountedApp, getElement } from "./elements.js"; +import { renderRecursively } from "./render.js"; +import { ack, createRandomUUID, TAB_ID, OLD_TAB_ID } from "./utils.js"; +import { logAndEmit } from "./utils.js"; +import { runJavascript, download } from "./utils.js"; + +let app = undefined; + +// Export getter for app so render.js can access it +export function getApp() { + return app; +} + +export function createApp(elements, options) { + Object.entries(elements).forEach(([_, element]) => replaceUndefinedAttributes(element)); + setInterval(() => ack(), 3000); + return (app = Vue.createApp({ + data() { + return { + elements, + }; + }, + render() { + return renderRecursively(this.elements, 0); + }, + mounted() { + setMountedApp(this); + window.documentId = createRandomUUID(); + window.clientId = options.query.client_id; + const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host; + window.path_prefix = options.prefix; + window.nextMessageId = options.query.next_message_id; + window.ackedMessageId = -1; + window.socket = io(url, { + path: `${options.prefix}/_nicegui_ws/socket.io`, + query: options.query, + extraHeaders: options.extraHeaders, + transports: + "prerendering" in document && document.prerendering === true + ? ["polling", ...options.transports] + : options.transports, + }); + window.did_handshake = false; + const messageHandlers = { + connect: () => { + function wrapFunction(originalFunction) { + const MAX_WEBSOCKET_MESSAGE_SIZE = 1000000 - 100; // 1MB without 100 bytes of slack for the message header + return function (...args) { + const msg = args[0]; + if (typeof msg === "string" && msg.length > MAX_WEBSOCKET_MESSAGE_SIZE) { + const errorMessage = `Payload size ${msg.length} exceeds the maximum allowed limit.`; + console.error(errorMessage); + args[0] = `42["log",{"client_id":"${window.clientId}","level":"error","message":"${errorMessage}"}]`; + if (window.tooLongMessageTimerId) clearTimeout(window.tooLongMessageTimerId); + const popup = document.getElementById("too_long_message_popup"); + popup.ariaHidden = false; + window.tooLongMessageTimerId = setTimeout(() => (popup.ariaHidden = true), 5000); + } + return originalFunction.call(this, ...args); + }; + } + const transport = window.socket.io.engine.transport; + if (transport?.ws?.send) transport.ws.send = wrapFunction(transport.ws.send); + if (transport?.doWrite) transport.doWrite = wrapFunction(transport.doWrite); + + const args = { + client_id: window.clientId, + document_id: window.documentId, + tab_id: TAB_ID, + old_tab_id: OLD_TAB_ID, + next_message_id: window.nextMessageId, + }; + window.socket.emit("handshake", args, (ok) => { + if (!ok) { + console.log("reloading because handshake failed for clientId " + window.clientId); + window.location.reload(); + } + window.did_handshake = true; + document.getElementById("popup").ariaHidden = true; + }); + }, + connect_error: (err) => { + if (err.message == "timeout") { + console.log("reloading because connection timed out"); + window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198 + } + }, + try_reconnect: async () => { + document.getElementById("popup").ariaHidden = false; + await fetch(window.location.href, { headers: { "NiceGUI-Check": "try_reconnect" } }); + console.log("reloading because reconnect was requested"); + window.location.reload(); + }, + disconnect: () => { + document.getElementById("popup").ariaHidden = false; + }, + load_js_components: async (msg) => { + const urls = msg.components.map((c) => `${options.prefix}/_nicegui/${options.version}/components/${c.key}`); + const imports = await Promise.all(urls.map((url) => import(url))); + msg.components.forEach((c, i) => app.component(c.tag, imports[i].default)); + }, + update: async (msg) => { + let eventListenersChanged = false; + for (const [id, element] of Object.entries(msg)) { + if (element === null) continue; + if (!(id in this.elements)) continue; + const oldListenerIds = new Set((this.elements[id]?.events || []).map((ev) => ev.listener_id)); + if (element.events?.some((e) => !oldListenerIds.has(e.listener_id))) { + delete this.elements[id]; + eventListenersChanged = true; + } + } + if (eventListenersChanged) { + logAndEmit("warning", "Event listeners changed after initial definition. Re-rendering affected elements."); + await this.$nextTick(); + } + + for (const [id, element] of Object.entries(msg)) { + if (element === null) { + delete this.elements[id]; + continue; + } + replaceUndefinedAttributes(element); + this.elements[id] = element; + } + + await this.$nextTick(); + for (const [id, element] of Object.entries(msg)) { + if (element?.update_method) { + getElement(id)?.[element.update_method](); + } + } + }, + run_javascript: (msg) => runJavascript(msg.code, msg.request_id), + open: (msg) => { + const url = msg.path.startsWith("/") ? options.prefix + msg.path : msg.path; + const target = msg.new_tab ? "_blank" : "_self"; + window.open(url, target); + }, + download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix), + notify: (msg) => Quasar.Notify.create(msg), + }; + const socketMessageQueue = []; + let isProcessingSocketMessage = false; + for (const [event, handler] of Object.entries(messageHandlers)) { + window.socket.on(event, async (...args) => { + if (args.length > 0 && args[0]._id !== undefined) { + const message_id = args[0]._id; + if (message_id < window.nextMessageId) return; + window.nextMessageId = message_id + 1; + delete args[0]._id; + } + socketMessageQueue.push(() => handler(...args)); + if (!isProcessingSocketMessage) { + while (socketMessageQueue.length > 0) { + const handler = socketMessageQueue.shift(); + isProcessingSocketMessage = true; + try { + await handler(); + } catch (e) { + console.error(e); + } + isProcessingSocketMessage = false; + } + } + }); + } + }, + })); +} diff --git a/nicegui/static/src/colors.js b/nicegui/static/src/colors.js new file mode 100644 index 0000000000..7086a611d1 --- /dev/null +++ b/nicegui/static/src/colors.js @@ -0,0 +1,19 @@ +export function applyColors(colors) { + const quasarColors = ["primary", "secondary", "accent", "dark", "dark-page", "positive", "negative", "info", "warning"]; + let customCSS = ""; + for (let color in colors) { + if (quasarColors.includes(color)) + continue; + const colorName = color.replaceAll("_", "-"); + const colorVar = "--q-" + colorName; + document.body.style.setProperty(colorVar, colors[color]); + customCSS += `.text-${colorName} { color: var(${colorVar}) !important; }\n`; + customCSS += `.bg-${colorName} { background-color: var(${colorVar}) !important; }\n`; + } + if (!customCSS) return; + const style = document.createElement("style"); + style.innerHTML = customCSS; + style.dataset.niceguiCustomColors = ""; + document.head.querySelectorAll("[data-nicegui-custom-colors]").forEach((el) => el.remove()); + document.getElementsByTagName("head")[0].appendChild(style); +} diff --git a/nicegui/static/src/constants.js b/nicegui/static/src/constants.js new file mode 100644 index 0000000000..d35b056698 --- /dev/null +++ b/nicegui/static/src/constants.js @@ -0,0 +1,3 @@ +export const True = true; +export const False = false; +export const None = undefined; diff --git a/nicegui/static/src/elements.js b/nicegui/static/src/elements.js new file mode 100644 index 0000000000..ef1bc43278 --- /dev/null +++ b/nicegui/static/src/elements.js @@ -0,0 +1,37 @@ +import { None } from "./constants.js"; + +let mounted_app = undefined; + +export function setMountedApp(app) { + mounted_app = app; +} + +export function getMountedApp() { + return mounted_app; +} + +export function replaceUndefinedAttributes(element) { + element.class ??= []; + element.style ??= {}; + element.props ??= {}; + element.text ??= null; + element.events ??= []; + element.update_method ??= null; + element.slots = { + default: { ids: element.children || [] }, + ...(element.slots ?? {}), + }; +} + +export function getElement(id) { + const _id = id instanceof Element ? id.id.slice(1) : id; + return mounted_app.$refs["r" + _id]; +} + +export function getHtmlElement(id) { + let id_as_a_string = id.toString(); + if (!id_as_a_string.startsWith("c")) { + id_as_a_string = "c" + id_as_a_string; + } + return document.getElementById(id_as_a_string); +} diff --git a/nicegui/static/src/events.js b/nicegui/static/src/events.js new file mode 100644 index 0000000000..af5c4cfd05 --- /dev/null +++ b/nicegui/static/src/events.js @@ -0,0 +1,59 @@ +export function stringifyEventArgs(args, event_args) { + const result = []; + args.forEach((arg, i) => { + if (event_args !== null && i >= event_args.length) return; + let filtered = {}; + if (typeof arg !== "object" || arg === null || Array.isArray(arg)) { + filtered = arg; + } else { + for (let k in arg) { + // ignore "Restricted" fields in Firefox (see #2469) + if (k == "originalTarget") { + try { + arg[k].toString(); + } catch (e) { + continue; + } + } + if (event_args === null || event_args[i] === null || event_args[i].includes(k)) { + filtered[k] = arg[k]; + } + } + } + result.push(JSON.stringify(filtered, (k, v) => (v instanceof Node || v instanceof Window ? undefined : v))); + }); + return result; +} + +const waitingCallbacks = new Map(); + +export function throttle(callback, time, leading, trailing, id) { + if (time <= 0) { + // execute callback immediately and return + callback(); + return; + } + if (waitingCallbacks.has(id)) { + if (trailing) { + // update trailing callback + waitingCallbacks.set(id, callback); + } + } else { + if (leading) { + // execute leading callback and set timeout to block more leading callbacks + callback(); + waitingCallbacks.set(id, null); + } else if (trailing) { + // set trailing callback and set timeout to execute it + waitingCallbacks.set(id, callback); + } + if (leading || trailing) { + // set timeout to remove block and to execute trailing callback + setTimeout(() => { + const trailingCallback = waitingCallbacks.get(id); + if (trailingCallback) trailingCallback(); + waitingCallbacks.delete(id); + }, 1000 * time); + } + } +} diff --git a/nicegui/static/src/index.js b/nicegui/static/src/index.js new file mode 100644 index 0000000000..9cdc037778 --- /dev/null +++ b/nicegui/static/src/index.js @@ -0,0 +1,31 @@ +// Import all modules +import { True, False, None } from "./constants.js"; +import { applyColors } from "./colors.js"; +import { getElement, getHtmlElement, replaceUndefinedAttributes, getMountedApp } from "./elements.js"; +import { parseElements, runMethod, getComputedProp, emitEvent, logAndEmit, runJavascript, download, ack, TAB_ID, OLD_TAB_ID } from "./utils.js"; +import { createApp, getApp } from "./app.js"; +import "./quasar-hack.js"; + +// Export everything - window assignments handled by esbuild footer +export { + True, + False, + None, + applyColors, + getElement, + getHtmlElement, + replaceUndefinedAttributes, + parseElements, + runMethod, + getComputedProp, + emitEvent, + logAndEmit, + runJavascript, + download, + ack, + createApp, + getApp, + getMountedApp, + TAB_ID, + OLD_TAB_ID, +}; diff --git a/nicegui/static/src/quasar-hack.js b/nicegui/static/src/quasar-hack.js new file mode 100644 index 0000000000..12e06c2004 --- /dev/null +++ b/nicegui/static/src/quasar-hack.js @@ -0,0 +1,10 @@ +// HACK: remove Quasar's rules for divs in QCard (#2265, #2301) +for (const importRule of document.styleSheets[0].cssRules) { + if (importRule instanceof CSSImportRule && /quasar/.test(importRule.styleSheet?.href)) { + for (const rule of Array.from(importRule.styleSheet.cssRules)) { + if (rule instanceof CSSStyleRule && /\.q-card > div/.test(rule.selectorText)) { + if (/\.q-card > div/.test(rule.selectorText)) rule.selectorText = ".nicegui-card-tight" + rule.selectorText; + } + } + } +} diff --git a/nicegui/static/src/render.js b/nicegui/static/src/render.js new file mode 100644 index 0000000000..560074411f --- /dev/null +++ b/nicegui/static/src/render.js @@ -0,0 +1,102 @@ +import { True, False, None } from "./constants.js"; +import { stringifyEventArgs, throttle } from "./events.js"; +import { getElement } from "./elements.js"; +import { getApp } from "./app.js"; + +export function renderRecursively(elements, id, propsContext) { + const element = elements[id]; + if (element === undefined) { + return; + } + + const props = { + id: "c" + id, + ref: "r" + id, + key: id, // HACK: workaround for #600 and #898 + class: element.class.join(" ") || undefined, + style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, "") || undefined, + ...element.props, + }; + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith(":")) { + try { + try { + props[key.substring(1)] = new Function("props", `return (${value})`)(propsContext); + } catch (e) { + props[key.substring(1)] = eval(value); + } + delete props[key]; + } catch (e) { + console.error(`Error while converting ${key} attribute to function:`, e); + } + } + }); + element.events.forEach((event) => { + let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1); + event.specials.forEach((s) => (event_name += s[0].toLocaleUpperCase() + s.substring(1))); + + const emit = (...args) => { + const emitter = () => + window.socket?.emit("event", { + id: id, + client_id: window.clientId, + listener_id: event.listener_id, + args: stringifyEventArgs(args, event.args), + }); + const delayed_emitter = () => { + if (window.did_handshake) emitter(); + else setTimeout(delayed_emitter, 10); + }; + throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id); + if (element.props["loopback"] === False && event.type == "update:modelValue") { + element.props["model-value"] = args; + } + }; + + let handler; + if (event.js_handler) { + const props = propsContext; // make `props` accessible from inside the event handler + handler = eval(event.js_handler); + } else { + handler = emit; + } + + handler = Vue.withModifiers(handler, event.modifiers); + handler = event.keys.length ? Vue.withKeys(handler, event.keys) : handler; + if (props[event_name]) { + props[event_name].push(handler); + } else { + props[event_name] = [handler]; + } + }); + const slots = {}; + const element_slots = { + default: { ids: element.children || [] }, + ...element.slots, + }; + Object.entries(element_slots).forEach(([name, data]) => { + slots[name] = (props) => { + const rendered = []; + if (data.template) { + rendered.push( + Vue.h( + { + props: { props: { type: Object, default: {} } }, + template: data.template, + }, + { + props: props, + } + ) + ); + } + const children = data.ids.map((id) => renderRecursively(elements, id, props || propsContext)); + if (name === "default" && element.text !== null) { + children.unshift(element.text); + } + return [...rendered, ...children]; + }; + }); + const app = getApp(); + return Vue.h(app.config.isNativeTag(element.tag) ? element.tag : Vue.resolveComponent(element.tag), props, slots); +} diff --git a/nicegui/static/src/utils.js b/nicegui/static/src/utils.js new file mode 100644 index 0000000000..e38a7b088b --- /dev/null +++ b/nicegui/static/src/utils.js @@ -0,0 +1,120 @@ +import { getElement } from "./elements.js"; + +export function parseElements(raw_elements) { + return JSON.parse( + raw_elements + .replace(/$/g, "$") + .replace(/`/g, "`") + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/&/g, "&") + ); +} + +export function runMethod(target, method_name, args) { + if (typeof target === "object") { + if (method_name in target) { + return target[method_name](...args); + } else { + return eval(method_name)(target, ...args); + } + } + const element = getElement(target); + if (element === null || element === undefined) return; + if (method_name in element) { + return element[method_name](...args); + } else if (method_name in (element.$refs.qRef || [])) { + return element.$refs.qRef[method_name](...args); + } else { + return eval(method_name)(element, ...args); + } +} + +export function getComputedProp(target, prop_name) { + if (typeof target === "object" && prop_name in target) { + return target[prop_name]; + } + const element = getElement(target); + if (element === null || element === undefined) return; + if (prop_name in element) { + return element[prop_name]; + } else if (prop_name in (element.$refs.qRef || [])) { + return element.$refs.qRef[prop_name]; + } +} + +export function emitEvent(event_name, ...args) { + getElement(0).$emit(event_name, ...args); +} + +export function logAndEmit(level, message) { + if (level === "error") { + console.error(message); + } else if (level === "warning") { + console.warn(message); + } else { + console.log(message); + } + window.socket.emit("log", { client_id: window.clientId, level, message }); +} + +export function runJavascript(code, request_id) { + new Promise((resolve) => resolve(eval(code))) + .catch((reason) => { + if (reason instanceof SyntaxError) return eval(`(async() => {${code}})()`); + else throw reason; + }) + .then((result) => { + if (request_id) { + window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result }); + } + }); +} + +export function download(src, filename, mediaType, prefix) { + const anchor = document.createElement("a"); + if (typeof src === "string") { + anchor.href = src.startsWith("/") ? prefix + src : src; + } else { + anchor.href = URL.createObjectURL(new Blob([src], { type: mediaType })); + } + anchor.target = "_blank"; + anchor.download = filename || ""; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + if (typeof src !== "string") { + URL.revokeObjectURL(anchor.href); + } +} + +export function ack() { + if (!window.socket || !window.did_handshake) return; + if (window.ackedMessageId >= window.nextMessageId) return; + window.socket.emit("ack", { + client_id: window.clientId, + next_message_id: window.nextMessageId, + }); + window.ackedMessageId = window.nextMessageId; +} + +export function createRandomUUID() { + try { + return crypto.randomUUID(); + } catch (e) { + // https://stackoverflow.com/a/2117523/3419103 + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16) + ); + } +} + +export const OLD_TAB_ID = sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id : null; +export const TAB_ID = + !sessionStorage.__nicegui_tab_id || sessionStorage.__nicegui_tab_closed === "false" + ? (sessionStorage.__nicegui_tab_id = createRandomUUID()) + : sessionStorage.__nicegui_tab_id; +sessionStorage.__nicegui_tab_closed = "false"; +window.onbeforeunload = function () { + sessionStorage.__nicegui_tab_closed = "true"; +};