465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
const True = true;
|
|
const False = false;
|
|
const None = undefined;
|
|
|
|
let app = undefined;
|
|
let mounted_app = undefined;
|
|
|
|
const loaded_components = new Set();
|
|
|
|
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.component ??= 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 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) {
|
|
const element = elements[id];
|
|
if (element === undefined) {
|
|
return;
|
|
}
|
|
|
|
// @todo: Try avoid this with better handling of initial page load.
|
|
if (element.component) loaded_components.add(element.component.name);
|
|
|
|
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(`return (${value})`)();
|
|
} 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) {
|
|
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));
|
|
if (name === "default" && element.text !== null) {
|
|
children.unshift(element.text);
|
|
}
|
|
return [...rendered, ...children];
|
|
};
|
|
});
|
|
return Vue.h(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;
|
|
}
|
|
|
|
async function loadDependencies(element, prefix, version) {
|
|
if (element.component) {
|
|
const { name, key, tag } = element.component;
|
|
if (!loaded_components.has(name) && !key.endsWith(".vue")) {
|
|
const component = await import(`${prefix}/_nicegui/${version}/components/${key}`);
|
|
app.component(tag, component.default);
|
|
loaded_components.add(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
console.error(`Payload size ${msg.length} exceeds the maximum allowed limit.`);
|
|
args[0] = '42["too_long_message"]';
|
|
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;
|
|
},
|
|
update: async (msg) => {
|
|
const loadPromises = Object.entries(msg)
|
|
.filter(([_, element]) => element && element.component)
|
|
.map(([_, element]) => loadDependencies(element, options.prefix, options.version));
|
|
await Promise.all(loadPromises);
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|