From ec92ce82b6a1a73cb722bc2d62cf21ff84066656 Mon Sep 17 00:00:00 2001 From: attila Date: Sat, 16 Sep 2023 16:02:33 +0200 Subject: [PATCH] WebBrowserComponent: Add native integration helper Javascript library --- .../native/javascript/check_native_interop.js | 112 +++++ .../juce_gui_extra/native/javascript/index.js | 387 ++++++++++++++++++ .../native/javascript/package.json | 4 + 3 files changed, 503 insertions(+) create mode 100644 modules/juce_gui_extra/native/javascript/check_native_interop.js create mode 100644 modules/juce_gui_extra/native/javascript/index.js create mode 100644 modules/juce_gui_extra/native/javascript/package.json diff --git a/modules/juce_gui_extra/native/javascript/check_native_interop.js b/modules/juce_gui_extra/native/javascript/check_native_interop.js new file mode 100644 index 0000000000..39dfe02ffd --- /dev/null +++ b/modules/juce_gui_extra/native/javascript/check_native_interop.js @@ -0,0 +1,112 @@ +if ( + typeof window.__JUCE__ !== "undefined" && + typeof window.__JUCE__.getAndroidUserScripts !== "undefined" && + typeof window.inAndroidUserScriptEval === "undefined" +) { + window.inAndroidUserScriptEval = true; + eval(window.__JUCE__.getAndroidUserScripts()); + delete window.inAndroidUserScriptEval; +} + +{ + if (typeof window.__JUCE__ === "undefined") { + console.warn( + "The 'window.__JUCE__' object is undefined." + + " Native integration features will not work." + + " Defining a placeholder 'window.__JUCE__' object." + ); + + window.__JUCE__ = { + postMessage: function () {}, + }; + } + + if (typeof window.__JUCE__.initialisationData === "undefined") { + window.__JUCE__.initialisationData = { + __juce__platform: [], + __juce__functions: [], + __juce__registeredGlobalEventIds: [], + __juce__sliders: [], + __juce__toggles: [], + __juce__comboBoxes: [], + }; + } + + class ListenerList { + constructor() { + this.listeners = new Map(); + this.listenerId = 0; + } + + addListener(fn) { + const newListenerId = this.listenerId++; + this.listeners.set(newListenerId, fn); + return newListenerId; + } + + removeListener(id) { + if (this.listeners.has(id)) { + this.listeners.delete(id); + } + } + + callListeners(payload) { + for (const [, value] of this.listeners) { + value(payload); + } + } + } + + class EventListenerList { + constructor() { + this.eventListeners = new Map(); + } + + addEventListener(eventId, fn) { + if (!this.eventListeners.has(eventId)) + this.eventListeners.set(eventId, new ListenerList()); + + const id = this.eventListeners.get(eventId).addListener(fn); + + return [eventId, id]; + } + + removeEventListener([eventId, id]) { + if (this.eventListeners.has(eventId)) { + this.eventListeners.get(eventId).removeListener(id); + } + } + + emitEvent(eventId, object) { + if (this.eventListeners.has(eventId)) + this.eventListeners.get(eventId).callListeners(object); + } + } + + class Backend { + constructor() { + this.listeners = new EventListenerList(); + } + + addEventListener(eventId, fn) { + return this.listeners.addEventListener(eventId, fn); + } + + removeEventListener([eventId, id]) { + this.listeners.removeEventListener(eventId, id); + } + + emitEvent(eventId, object) { + window.__JUCE__.postMessage( + JSON.stringify({ eventId: eventId, payload: object }) + ); + } + + emitByBackend(eventId, object) { + this.listeners.emitEvent(eventId, JSON.parse(object)); + } + } + + if (typeof window.__JUCE__.backend === "undefined") + window.__JUCE__.backend = new Backend(); +} diff --git a/modules/juce_gui_extra/native/javascript/index.js b/modules/juce_gui_extra/native/javascript/index.js new file mode 100644 index 0000000000..6d345ee3bf --- /dev/null +++ b/modules/juce_gui_extra/native/javascript/index.js @@ -0,0 +1,387 @@ +import "./check_native_interop.js"; + +class PromiseHandler { + constructor() { + this.lastPromiseId = 0; + this.promises = new Map(); + + window.__JUCE__.backend.addEventListener( + "__juce__complete", + ({ promiseId, result }) => { + if (this.promises.has(promiseId)) { + this.promises.get(promiseId).resolve(result); + this.promises.delete(promiseId); + } + } + ); + } + + createPromise() { + const promiseId = this.lastPromiseId++; + const result = new Promise((resolve, reject) => { + this.promises.set(promiseId, { resolve: resolve, reject: reject }); + }); + return [promiseId, result]; + } +} + +const promiseHandler = new PromiseHandler(); + +/** + * Returns a function object that calls a function registered on the JUCE backend and forwards all + * parameters to it. + * + * The provided name should be the same as the name argument passed to + * WebBrowserComponent::Options.withNativeFunction() on the backend. + * + * @param {String} name + */ +function getNativeFunction(name) { + if (!window.__JUCE__.initialisationData.__juce__functions.includes(name)) + console.warn( + `Creating native function binding for '${name}', which is unknown to the backend` + ); + + const f = function () { + const [promiseId, result] = promiseHandler.createPromise(); + + window.__JUCE__.backend.emitEvent("__juce__invoke", { + name: name, + params: Array.prototype.slice.call(arguments), + resultId: promiseId, + }); + + return result; + }; + + return f; +} + +//============================================================================== + +class ListenerList { + constructor() { + this.listeners = new Map(); + this.listenerId = 0; + } + + addListener(fn) { + const newListenerId = this.listenerId++; + this.listeners.set(newListenerId, fn); + return newListenerId; + } + + removeListener(id) { + if (this.listeners.has(id)) { + this.listeners.delete(id); + } + } + + callListeners(payload) { + for (const [, value] of this.listeners) { + value(payload); + } + } +} + +const BasicControl_valueChangedEventId = "valueChanged"; +const BasicControl_propertiesChangedId = "propertiesChanged"; + +class SliderState { + constructor(name) { + if (!window.__JUCE__.initialisationData.__juce__sliders.includes(name)) + console.warn( + "Creating SliderState for '" + + name + + "', which is unknown to the backend" + ); + + this.name = name; + this.identifier = "__juce__slider" + this.name; + this.scaledValue = 0; + this.properties = { + start: 0, + end: 1, + skew: 1, + name: "", + label: "", + numSteps: 100, + interval: 0, + }; + this.valueChangedEvent = new ListenerList(); + this.propertiesChangedEvent = new ListenerList(); + + window.__JUCE__.backend.addEventListener(this.identifier, (event) => + this.handleEvent(event) + ); + + window.__JUCE__.backend.emitEvent(this.identifier, { + eventType: "requestInitialUpdate", + }); + } + + setNormalisedValue(newValue) { + this.scaledValue = this.snapToLegalValue( + this.normalisedToScaledValue(newValue) + ); + + window.__JUCE__.backend.emitEvent(this.identifier, { + eventType: BasicControl_valueChangedEventId, + value: this.scaledValue, + }); + } + + sliderDragStarted() {} + + sliderDragEnded() {} + + handleEvent(event) { + if (event.eventType == BasicControl_valueChangedEventId) { + this.scaledValue = event.value; + this.valueChangedEvent.callListeners(); + } + if (event.eventType == BasicControl_propertiesChangedId) { + // eslint-disable-next-line no-unused-vars + let { eventType: _, ...rest } = event; + this.properties = rest; + this.propertiesChangedEvent.callListeners(); + } + } + + getScaledValue() { + return this.scaledValue; + } + + getNormalisedValue() { + return Math.pow( + (this.scaledValue - this.properties.start) / + (this.properties.end - this.properties.start), + this.properties.skew + ); + } + + normalisedToScaledValue(normalisedValue) { + return ( + Math.pow(normalisedValue, 1 / this.properties.skew) * + (this.properties.end - this.properties.start) + + this.properties.start + ); + } + + snapToLegalValue(value) { + const interval = this.properties.interval; + + if (interval == 0) return value; + + const start = this.properties.start; + const clamp = (val, min = 0, max = 1) => Math.max(min, Math.min(max, val)); + + return clamp( + start + interval * Math.floor((value - start) / interval + 0.5), + this.properties.start, + this.properties.end + ); + } +} + +const sliderStates = new Map(); + +for (const sliderName of window.__JUCE__.initialisationData.__juce__sliders) + sliderStates.set(sliderName, new SliderState(sliderName)); + +/** + * Returns a SliderState object that is connected to the backend WebSliderRelay object that was + * created with the same name argument. + * + * To register a WebSliderRelay object create one with the right name and add it to the + * WebBrowserComponent::Options struct using withOptionsFrom. + * + * @param {String} name + */ +function getSliderState(name) { + if (!sliderStates.has(name)) sliderStates.set(name, new SliderState(name)); + + return sliderStates.get(name); +} + +class ToggleState { + constructor(name) { + if (!window.__JUCE__.initialisationData.__juce__toggles.includes(name)) + console.warn( + "Creating ToggleState for '" + + name + + "', which is unknown to the backend" + ); + + this.name = name; + this.identifier = "__juce__toggle" + this.name; + this.value = false; + this.properties = { + name: "", + }; + this.valueChangedEvent = new ListenerList(); + this.propertiesChangedEvent = new ListenerList(); + + window.__JUCE__.backend.addEventListener(this.identifier, (event) => + this.handleEvent(event) + ); + + window.__JUCE__.backend.emitEvent(this.identifier, { + eventType: "requestInitialUpdate", + }); + } + + getValue() { + return this.value; + } + + setValue(newValue) { + this.value = newValue; + + window.__JUCE__.backend.emitEvent(this.identifier, { + eventType: BasicControl_valueChangedEventId, + value: this.value, + }); + } + + handleEvent(event) { + if (event.eventType == BasicControl_valueChangedEventId) { + this.value = event.value; + this.valueChangedEvent.callListeners(); + } + if (event.eventType == BasicControl_propertiesChangedId) { + // eslint-disable-next-line no-unused-vars + let { eventType: _, ...rest } = event; + this.properties = rest; + this.propertiesChangedEvent.callListeners(); + } + } +} + +const toggleStates = new Map(); + +for (const name of window.__JUCE__.initialisationData.__juce__toggles) + toggleStates.set(name, new ToggleState(name)); + +/** + * Returns a ToggleState object that is connected to the backend WebToggleButtonRelay object that was + * created with the same name argument. + * + * To register a WebToggleButtonRelay object create one with the right name and add it to the + * WebBrowserComponent::Options struct using withOptionsFrom. + * + * @param {String} name + */ +function getToggleState(name) { + if (!toggleStates.has(name)) toggleStates.set(name, new ToggleState(name)); + + return toggleStates.get(name); +} + +class ComboBoxState { + constructor(name) { + if (!window.__JUCE__.initialisationData.__juce__comboBoxes.includes(name)) + console.warn( + "Creating ComboBoxState for '" + + name + + "', which is unknown to the backend" + ); + + this.name = name; + this.identifier = "__juce__comboBox" + this.name; + this.value = 0.0; + this.properties = { + name: "", + choices: [], + }; + this.valueChangedEvent = new ListenerList(); + this.propertiesChangedEvent = new ListenerList(); + + window.__JUCE__.backend.addEventListener(this.identifier, (event) => + this.handleEvent(event) + ); + + window.__JUCE__.backend.emitEvent(this.identifier, { + eventType: "requestInitialUpdate", + }); + } + + getChoiceIndex() { + return Math.round(this.value * (this.properties.choices.length - 1)); + } + + setChoiceIndex(index) { + const numItems = this.properties.choices.length; + this.value = numItems > 1 ? index / (numItems - 1) : 0.0; + + window.__JUCE__.backend.emitEvent(this.identifier, { + eventType: BasicControl_valueChangedEventId, + value: this.value, + }); + } + + handleEvent(event) { + if (event.eventType == BasicControl_valueChangedEventId) { + this.value = event.value; + this.valueChangedEvent.callListeners(); + } + if (event.eventType == BasicControl_propertiesChangedId) { + // eslint-disable-next-line no-unused-vars + let { eventType: _, ...rest } = event; + this.properties = rest; + this.propertiesChangedEvent.callListeners(); + } + } +} + +const comboBoxStates = new Map(); + +for (const name of window.__JUCE__.initialisationData.__juce__comboBoxes) + comboBoxStates.set(name, new ComboBoxState(name)); + +/** + * Returns a ComboBoxState object that is connected to the backend WebComboBoxRelay object that was + * created with the same name argument. + * + * To register a WebComboBoxRelay object create one with the right name and add it to the + * WebBrowserComponent::Options struct using withOptionsFrom. + * + * @param {String} name + */ +function getComboBoxState(name) { + if (!comboBoxStates.has(name)) + comboBoxStates.set(name, new ComboBoxState(name)); + + return comboBoxStates.get(name); +} + +/** + * Appends a platform-specific prefix to the path to ensure that a request sent to this address will + * be received by the backend's ResourceProvider. + * @param {String} path + */ +function getBackendResourceAddress(path) { + const platform = + window.__JUCE__.initialisationData.__juce__platform.length > 0 + ? window.__JUCE__.initialisationData.__juce__platform[0] + : ""; + + if (platform == "windows" || platform == "android") + return "https://juce.backend/" + path; + + if (platform == "macos" || platform == "ios" || platform == "linux") + return "juce://juce.backend/" + path; + + console.warn( + "getBackendResourceAddress() called, but no JUCE native backend is detected." + ); + return path; +} + +export { + getNativeFunction, + getSliderState, + getToggleState, + getComboBoxState, + getBackendResourceAddress, +}; diff --git a/modules/juce_gui_extra/native/javascript/package.json b/modules/juce_gui_extra/native/javascript/package.json new file mode 100644 index 0000000000..49409b4190 --- /dev/null +++ b/modules/juce_gui_extra/native/javascript/package.json @@ -0,0 +1,4 @@ +{ + "name": "juce-framework-frontend", + "version": "7.0.7" +}