import FSItem from './FSItem.js'; import PuterDialog from './PuterDialog.js'; import EventListener from '../lib/EventListener.js'; // AppConnection provides an API for interacting with another app. // It's returned by UI methods, and cannot be constructed directly by user code. // For basic usage: // - postMessage(message) Send a message to the target app // - on('message', callback) Listen to messages from the target app class AppConnection extends EventListener { // targetOrigin for postMessage() calls to Puter #puterOrigin = '*'; // Whether the target app is open #isOpen; // Whether the target app uses the Puter SDK, and so accepts messages // (Closing and close events will still function.) #usesSDK; constructor(messageTarget, appInstanceID, targetAppInstanceID, usesSDK) { super([ 'message', // The target sent us something with postMessage() 'close', // The target app was closed ]); this.messageTarget = messageTarget; this.appInstanceID = appInstanceID; this.targetAppInstanceID = targetAppInstanceID; this.#isOpen = true; this.#usesSDK = usesSDK; // TODO: Set this.#puterOrigin to the puter origin window.addEventListener('message', event => { if (event.data.msg === 'messageToApp') { if (event.data.appInstanceID !== this.targetAppInstanceID) { // Message is from a different AppConnection; ignore it. return; } if (event.data.targetAppInstanceID !== this.appInstanceID) { console.error(`AppConnection received message intended for wrong app! appInstanceID=${this.appInstanceID}, target=${event.data.targetAppInstanceID}`); return; } this.emit('message', event.data.contents); return; } if (event.data.msg === 'appClosed') { if (event.data.appInstanceID !== this.targetAppInstanceID) { // Message is from a different AppConnection; ignore it. return; } this.#isOpen = false; this.emit('close', { appInstanceID: this.targetAppInstanceID, }); } }); } // Does the target app use the Puter SDK? If not, certain features will be unavailable. get usesSDK() { return this.#usesSDK; } // Send a message to the target app. Requires the target to use the Puter SDK. postMessage(message) { if (!this.#isOpen) { console.warn('Trying to post message on a closed AppConnection'); return; } if (!this.#usesSDK) { console.warn('Trying to post message to a non-SDK app'); return; } this.messageTarget.postMessage({ msg: 'messageToApp', appInstanceID: this.appInstanceID, targetAppInstanceID: this.targetAppInstanceID, targetAppOrigin: '*', // TODO: Specify this somehow contents: message, }, this.#puterOrigin); } // Attempt to close the target application close() { if (!this.#isOpen) { console.warn('Trying to close an app on a closed AppConnection'); return; } this.messageTarget.postMessage({ msg: 'closeApp', appInstanceID: this.appInstanceID, targetAppInstanceID: this.targetAppInstanceID, }, this.#puterOrigin); } } class UI extends EventListener { // Used to generate a unique message id for each message sent to the host environment // we start from 1 because 0 is falsy and we want to avoid that for the message id #messageID = 1; // Holds the callback functions for the various events // that are triggered when a watched item has changed. itemWatchCallbackFunctions = []; // Holds the unique app instance ID that is provided by the host environment appInstanceID; // Holds the unique app instance ID for the parent (if any), which is provided by the host environment parentInstanceID; // If we have a parent app, holds an AppConnection to it #parentAppConnection = null; // Holds the callback functions for the various events // that can be triggered by the host environment's messages. #callbackFunctions = []; // onWindowClose() is executed right before the window is closed. Users can override this function // to perform a variety of tasks right before window is closed. Users can override this function. #onWindowClose; // When an item is opened by this app in any way onItemsOpened() is executed. Users can override this function. #onItemsOpened; #onLaunchedWithItems; // List of events that can be listened to. #eventNames; // The most recent value that we received for a given broadcast, by name. #lastBroadcastValue = new Map(); // name -> data // Replaces boilerplate for most methods: posts a message to the GUI with a unique ID, and sets a callback for it. #postMessageWithCallback = function(name, resolve, args = {}) { const msg_id = this.#messageID++; this.messageTarget?.postMessage({ msg: name, env: this.env, appInstanceID: this.appInstanceID, uuid: msg_id, ...args, }, '*'); //register callback this.#callbackFunctions[msg_id] = resolve; } constructor (appInstanceID, parentInstanceID, appID, env) { const eventNames = [ 'localeChanged', 'themeChanged', ]; super(eventNames); this.#eventNames = eventNames; this.appInstanceID = appInstanceID; this.parentInstanceID = parentInstanceID; this.appID = appID; this.env = env; if(this.env === 'app'){ this.messageTarget = window.parent; } else if(this.env === 'gui'){ return; } if (this.parentInstanceID) { this.#parentAppConnection = new AppConnection(this.messageTarget, this.appInstanceID, this.parentInstanceID, true); } // Tell the host environment that this app is using the Puter SDK and is ready to receive messages, // this will allow the OS to send custom messages to the app this.messageTarget?.postMessage({ msg: "READY", appInstanceID: this.appInstanceID, }, '*'); // When this app's window is focused send a message to the host environment window.addEventListener('focus', (e) => { this.messageTarget?.postMessage({ msg: "windowFocused", appInstanceID: this.appInstanceID, }, '*'); }); // Bind the message event listener to the window let lastDraggedOverElement = null; window.addEventListener('message', async (e) => { // `error` if(e.data.error){ throw e.data.error; } // `focus` event else if(e.data.msg && e.data.msg === 'focus'){ window.focus(); } // `click` event else if(e.data.msg && e.data.msg === 'click'){ // Get the element that was clicked on and click it const clicked_el = document.elementFromPoint(e.data.x, e.data.y); if(clicked_el !== null) clicked_el.click(); } // `dragover` event based on the `drag` event from the host environment else if(e.data.msg && e.data.msg === 'drag'){ // Get the element being dragged over const draggedOverElement = document.elementFromPoint(e.data.x, e.data.y); if(draggedOverElement !== lastDraggedOverElement){ // If the last element exists and is different from the current, dispatch a dragleave on it if(lastDraggedOverElement){ const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true, clientX: e.data.x, clientY: e.data.y }); lastDraggedOverElement.dispatchEvent(dragLeaveEvent); } // If the current element exists and is different from the last, dispatch dragenter on it if(draggedOverElement){ const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true, clientX: e.data.x, clientY: e.data.y }); draggedOverElement.dispatchEvent(dragEnterEvent); } // Update the lastDraggedOverElement lastDraggedOverElement = draggedOverElement; } } // `drop` event else if(e.data.msg && e.data.msg === 'drop'){ if(lastDraggedOverElement){ const dropEvent = new CustomEvent('drop', { bubbles: true, cancelable: true, detail: { clientX: e.data.x, clientY: e.data.y, items: e.data.items } }); lastDraggedOverElement.dispatchEvent(dropEvent); // Reset the lastDraggedOverElement lastDraggedOverElement = null; } } // windowWillClose else if(e.data.msg === 'windowWillClose'){ // If the user has not overridden onWindowClose() then send a message back to the host environment // to let it know that it is ok to close the window. if(this.#onWindowClose === undefined){ this.messageTarget?.postMessage({ msg: true, appInstanceID: this.appInstanceID, original_msg_id: e.data.msg_id, }, '*'); } // If the user has overridden onWindowClose() then send a message back to the host environment // to let it know that it is NOT ok to close the window. Then execute onWindowClose() and the user will // have to manually close the window. else{ this.messageTarget?.postMessage({ msg: false, appInstanceID: this.appInstanceID, original_msg_id: e.data.msg_id, }, '*'); this.#onWindowClose(); } } // itemsOpened else if(e.data.msg === 'itemsOpened'){ // If the user has not overridden onItemsOpened() then only send a message back to the host environment if(this.#onItemsOpened === undefined){ this.messageTarget?.postMessage({ msg: true, appInstanceID: this.appInstanceID, original_msg_id: e.data.msg_id, }, '*'); } // If the user has overridden onItemsOpened() then send a message back to the host environment // and execute onItemsOpened() else{ this.messageTarget?.postMessage({ msg: false, appInstanceID: this.appInstanceID, original_msg_id: e.data.msg_id, }, '*'); let items = []; if(e.data.items.length > 0){ for (let index = 0; index < e.data.items.length; index++) items.push(new FSItem(e.data.items[index])) } this.#onItemsOpened(items); } } // getAppDataSucceeded else if(e.data.msg === 'getAppDataSucceeded'){ let appDataItem = new FSItem(e.data.item); if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){ this.#callbackFunctions[e.data.original_msg_id](appDataItem); } } // readAppDataFileSucceeded else if(e.data.msg === 'readAppDataFileSucceeded'){ let appDataItem = new FSItem(e.data.item); if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){ this.#callbackFunctions[e.data.original_msg_id](appDataItem); } } // readAppDataFileFailed else if(e.data.msg === 'readAppDataFileFailed'){ if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){ this.#callbackFunctions[e.data.original_msg_id](null); } } // Determine if this is a response to a previous message and if so, is there // a callback function for this message? if answer is yes to both then execute the callback else if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){ if(e.data.msg === 'fileOpenPicked'){ // 1 item returned if(e.data.items.length === 1){ this.#callbackFunctions[e.data.original_msg_id](new FSItem(e.data.items[0])); } // multiple items returned else if(e.data.items.length > 1){ // multiple items returned let items = []; for (let index = 0; index < e.data.items.length; index++) items.push(new FSItem(e.data.items[index])) this.#callbackFunctions[e.data.original_msg_id](items); } } else if(e.data.msg === 'directoryPicked'){ // 1 item returned if(e.data.items.length === 1){ this.#callbackFunctions[e.data.original_msg_id](new FSItem({ uid: e.data.items[0].uid, name: e.data.items[0].fsentry_name, path: e.data.items[0].path, readURL: e.data.items[0].read_url, writeURL: e.data.items[0].write_url, metadataURL: e.data.items[0].metadata_url, isDirectory: true, size: e.data.items[0].fsentry_size, accessed: e.data.items[0].fsentry_accessed, modified: e.data.items[0].fsentry_modified, created: e.data.items[0].fsentry_created, })); } // multiple items returned else if(e.data.items.length > 1){ // multiple items returned let items = []; for (let index = 0; index < e.data.items.length; index++) items.push(new FSItem(e.data.items[index])) this.#callbackFunctions[e.data.original_msg_id](items); } } else if(e.data.msg === 'colorPicked'){ // execute callback this.#callbackFunctions[e.data.original_msg_id](e.data.color); } else if(e.data.msg === 'fontPicked'){ // execute callback this.#callbackFunctions[e.data.original_msg_id](e.data.font); } else if(e.data.msg === 'alertResponded'){ // execute callback this.#callbackFunctions[e.data.original_msg_id](e.data.response); } else if(e.data.msg === 'promptResponded'){ // execute callback this.#callbackFunctions[e.data.original_msg_id](e.data.response); } else if(e.data.msg === "fileSaved"){ // execute callback this.#callbackFunctions[e.data.original_msg_id](new FSItem(e.data.saved_file)); } else if (e.data.msg === 'childAppLaunched') { // execute callback with a new AppConnection to the child const connection = new AppConnection(this.messageTarget, this.appInstanceID, e.data.child_instance_id, e.data.uses_sdk); this.#callbackFunctions[e.data.original_msg_id](connection); } else{ // execute callback this.#callbackFunctions[e.data.original_msg_id](e.data); } //remove this callback function since it won't be needed again delete this.#callbackFunctions[e.data.original_msg_id]; } // Item Watch response else if(e.data.msg === "itemChanged" && e.data.data && e.data.data.uid){ //excute callback if(itemWatchCallbackFunctions[e.data.data.uid] && typeof itemWatchCallbackFunctions[e.data.data.uid] === 'function') itemWatchCallbackFunctions[e.data.data.uid](e.data.data); } // Broadcasts else if (e.data.msg === 'broadcast') { const { name, data } = e.data; if (!this.#eventNames.includes(name)) { return; } this.emit(name, data); this.#lastBroadcastValue.set(name, data); } }); } onWindowClose = function(callback) { this.#onWindowClose = callback; } onItemsOpened = function(callback) { // DEPRECATED - this is also called when items are dropped on the app, which in new versions should be handled // with the 'drop' event. // Check if a file was opened with this app, i.e. check URL parameters of window/iframe // Even though the file has been opened when the app is launched, we need to wait for the onItemsOpened callback to be set // before we can call it. This is why we need to check the URL parameters here. // This should also be done only the very first time the callback is set (hence the if(!this.#onItemsOpened) check) since // the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times. if(!this.#onItemsOpened){ let URLParams = new URLSearchParams(window.location.search); if(URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url')){ let fpath = URLParams.get('puter.item.path'); fpath = `~/` + fpath.split('/').slice(2).join('/'); callback([new FSItem({ name: URLParams.get('puter.item.name'), path: fpath, uid: URLParams.get('puter.item.uid'), readURL: URLParams.get('puter.item.read_url'), writeURL: URLParams.get('puter.item.write_url'), metadataURL: URLParams.get('puter.item.metadata_url'), size: URLParams.get('puter.item.size'), accessed: URLParams.get('puter.item.accessed'), modified: URLParams.get('puter.item.modified'), created: URLParams.get('puter.item.created'), })]); } } this.#onItemsOpened = callback; } onLaunchedWithItems = function(callback) { // Check if a file was opened with this app, i.e. check URL parameters of window/iframe // Even though the file has been opened when the app is launched, we need to wait for the onLaunchedWithItems callback to be set // before we can call it. This is why we need to check the URL parameters here. // This should also be done only the very first time the callback is set (hence the if(!this.#onLaunchedWithItems) check) since // the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times. if(!this.#onLaunchedWithItems){ let URLParams = new URLSearchParams(window.location.search); if(URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url')){ let fpath = URLParams.get('puter.item.path'); fpath = `~/` + fpath.split('/').slice(2).join('/'); callback([new FSItem({ name: URLParams.get('puter.item.name'), path: fpath, uid: URLParams.get('puter.item.uid'), readURL: URLParams.get('puter.item.read_url'), writeURL: URLParams.get('puter.item.write_url'), metadataURL: URLParams.get('puter.item.metadata_url'), size: URLParams.get('puter.item.size'), accessed: URLParams.get('puter.item.accessed'), modified: URLParams.get('puter.item.modified'), created: URLParams.get('puter.item.created'), })]); } } this.#onLaunchedWithItems = callback; } alert = function(message, buttons, options, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('ALERT', resolve, { message, buttons, options }); }) } prompt = function(message, placeholder, options, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('PROMPT', resolve, { message, placeholder, options }); }) } showDirectoryPicker = function(options, callback){ return new Promise((resolve) => { const msg_id = this.#messageID++; if(this.env === 'app'){ this.messageTarget?.postMessage({ msg: "showDirectoryPicker", appInstanceID: this.appInstanceID, uuid: msg_id, options: options, env: this.env, }, '*'); }else{ let w = 700; let h = 400; let title = 'Puter: Open Directory'; var left = (screen.width/2)-(w/2); var top = (screen.height/2)-(h/2); window.open(`${puter.defaultGUIOrigin}/action/show-directory-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options)}`, title, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left); } //register callback this.#callbackFunctions[msg_id] = resolve; }) } showOpenFilePicker = function(options, callback){ return new Promise((resolve) => { const msg_id = this.#messageID++; if(this.env === 'app'){ this.messageTarget?.postMessage({ msg: "showOpenFilePicker", appInstanceID: this.appInstanceID, uuid: msg_id, options: options ?? {}, env: this.env, }, '*'); }else{ let w = 700; let h = 400; let title = 'Puter: Open File'; var left = (screen.width/2)-(w/2); var top = (screen.height/2)-(h/2); window.open(`${puter.defaultGUIOrigin}/action/show-open-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options ?? {})}`, title, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left); } //register callback this.#callbackFunctions[msg_id] = resolve; }) } showFontPicker = function(options){ return new Promise((resolve) => { this.#postMessageWithCallback('showFontPicker', resolve, { options: options ?? {} }); }) } showColorPicker = function(options){ return new Promise((resolve) => { this.#postMessageWithCallback('showColorPicker', resolve, { options: options ?? {} }); }) } showSaveFilePicker = function(content, suggestedName){ return new Promise((resolve) => { const msg_id = this.#messageID++; const url = (Object.prototype.toString.call(content) === '[object URL]' ? content : undefined); if(this.env === 'app'){ this.messageTarget?.postMessage({ msg: "showSaveFilePicker", appInstanceID: this.appInstanceID, content: url ? undefined : content, url: url ? url.toString() : undefined, suggestedName: suggestedName ?? '', env: this.env, uuid: msg_id }, '*'); }else{ window.addEventListener('message', async (e) => { if(e.data?.msg === "sendMeFileData"){ // Send the blob URL to the host environment e.source.postMessage({ msg: "showSaveFilePickerPopup", content: url ? undefined : content, url: url ? url.toString() : undefined, suggestedName: suggestedName ?? '', env: this.env, uuid: msg_id }, '*'); // remove the event listener window.removeEventListener('message', this); } }); // Create a Blob from your binary data let blob = new Blob([content], {type: 'application/octet-stream'}); // Create an object URL for the Blob let objectUrl = URL.createObjectURL(blob); let w = 700; let h = 400; let title = 'Puter: Save File'; var left = (screen.width/2)-(w/2); var top = (screen.height/2)-(h/2); window.open(`${puter.defaultGUIOrigin}/action/show-save-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&blobUrl=${encodeURIComponent(objectUrl)}`, title, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left); } //register callback this.#callbackFunctions[msg_id] = resolve; }) } setWindowTitle = function(title, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('setWindowTitle', resolve, { new_title: title }); }) } setWindowWidth = function(width, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('setWindowWidth', resolve, { width }); }) } setWindowHeight = function(height, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('setWindowHeight', resolve, { height }); }) } setWindowSize = function(width, height, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('setWindowSize', resolve, { width, height }); }) } setWindowPosition = function(x, y, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('setWindowPosition', resolve, { x, y }); }) } /** * Asynchronously extracts entries from DataTransferItems, like files and directories. * * @private * @function * @async * @param {DataTransferItemList} dataTransferItems - List of data transfer items from a drag-and-drop operation. * @param {Object} [options={}] - Optional settings. * @param {boolean} [options.raw=false] - Determines if the file path should be processed. * @returns {Promise>} - A promise that resolves to an array of File or Entry objects. * @throws {Error} - Throws an error if there's an EncodingError and provides information about how to solve it. * * @example * const items = event.dataTransfer.items; * const entries = await getEntriesFromDataTransferItems(items, { raw: false }); */ getEntriesFromDataTransferItems = async function(dataTransferItems, options = { raw: false }) { const checkErr = (err) => { if (this.getEntriesFromDataTransferItems.didShowInfo) return if (err.name !== 'EncodingError') return this.getEntriesFromDataTransferItems.didShowInfo = true const infoMsg = `${err.name} occured within datatransfer-files-promise module\n` + `Error message: "${err.message}"\n` + 'Try serving html over http if currently you are running it from the filesystem.' console.warn(infoMsg) } const readFile = (entry, path = '') => { return new Promise((resolve, reject) => { entry.file(file => { if (!options.raw) file.filepath = path + file.name // save full path resolve(file) }, (err) => { checkErr(err) reject(err) }) }) } const dirReadEntries = (dirReader, path) => { return new Promise((resolve, reject) => { dirReader.readEntries(async entries => { let files = [] for (let entry of entries) { const itemFiles = await getFilesFromEntry(entry, path) files = files.concat(itemFiles) } resolve(files) }, (err) => { checkErr(err) reject(err) }) }) } const readDir = async (entry, path) => { const dirReader = entry.createReader() const newPath = path + entry.name + '/' let files = [] let newFiles do { newFiles = await dirReadEntries(dirReader, newPath) files = files.concat(newFiles) } while (newFiles.length > 0) return files } const getFilesFromEntry = async (entry, path = '') => { if(entry === null) return; else if (entry.isFile) { const file = await readFile(entry, path) return [file] } else if (entry.isDirectory) { const files = await readDir(entry, path) files.push(entry) return files } } let files = [] let entries = [] // Pull out all entries before reading them for (let i = 0, ii = dataTransferItems.length; i < ii; i++) { entries.push(dataTransferItems[i].webkitGetAsEntry()) } // Recursively read through all entries for (let entry of entries) { const newFiles = await getFilesFromEntry(entry) files = files.concat(newFiles) } return files } authenticateWithPuter = function() { if(this.env !== 'web'){ return; } // if authToken is already present, resolve immediately if(this.authToken){ return new Promise((resolve) => { resolve(); }) } // If a prompt is already open, return a promise that resolves based on the existing prompt's result. if (puter.puterAuthState.isPromptOpen) { return new Promise((resolve, reject) => { puter.puterAuthState.resolver = { resolve, reject }; }); } // Show the permission prompt and set the state. puter.puterAuthState.isPromptOpen = true; puter.puterAuthState.authGranted = null; return new Promise((resolve, reject) => { if (!puter.authToken) { const puterDialog = new PuterDialog(resolve, reject); document.body.appendChild(puterDialog); puterDialog.open(); } else { // If authToken is already present, resolve immediately resolve(); } }); } // Returns a Promise launchApp = function(appName, args, callback) { return new Promise((resolve) => { // if appName is an object and args is not set, then appName is actually args if (typeof appName === 'object' && !args) { args = appName; appName = undefined; } this.#postMessageWithCallback('launchApp', resolve, { app_name: appName, args }); }) } parentApp() { return this.#parentAppConnection; } createWindow = function (options, callback) { return new Promise((resolve) => { this.#postMessageWithCallback('createWindow', resolve, { options: options ?? {} }); }) } // Menubar menubar = function(){ // Remove previous style tag document.querySelectorAll('style.puter-stylesheet').forEach(function(el) { el.remove(); }) // Add new style tag const style = document.createElement('style'); style.classList.add('puter-stylesheet'); style.innerHTML = ` .--puter-menubar { border-bottom: 1px solid #e9e9e9; background-color: #fbf9f9; padding-top: 3px; padding-bottom: 2px; display: inline-block; position: fixed; top: 0; width: 100%; margin: 0; padding: 0; height: 31px; font-family: Arial, Helvetica, sans-serif; font-size: 13px; z-index: 9999; } .--puter-menubar, .--puter-menubar * { user-select: none; -webkit-user-select: none; cursor: default; } .--puter-menubar .dropdown-item-divider>hr { margin-top: 5px; margin-bottom: 5px; border-bottom: none; border-top: 1px solid #00000033; } .--puter-menubar>li { display: inline-block; padding: 10px 5px; } .--puter-menubar>li>ul { display: none; z-index: 999999999999; list-style: none; background-color: rgb(233, 233, 233); width: 200px; border: 1px solid #e4ebf3de; box-shadow: 0px 0px 5px #00000066; padding-left: 6px; padding-right: 6px; padding-top: 4px; padding-bottom: 4px; color: #333; border-radius: 4px; padding: 2px; min-width: 200px; margin-top: 5px; position: absolute; } .--puter-menubar .menubar-item { display: block; line-height: 24px; margin-top: -7px; text-align: center; border-radius: 3px; padding: 0 5px; } .--puter-menubar .menubar-item-open { background-color: rgb(216, 216, 216); } .--puter-menubar .dropdown-item { padding: 5px; padding: 5px 30px; list-style-type: none; user-select: none; font-size: 13px; } .--puter-menubar .dropdown-item-icon, .--puter-menubar .dropdown-item-icon-active { pointer-events: none; width: 18px; height: 18px; margin-left: -23px; margin-bottom: -4px; margin-right: 5px; } .--puter-menubar .dropdown-item-disabled .dropdown-item-icon{ display: inline-block !important; } .--puter-menubar .dropdown-item-disabled .dropdown-item-icon-active{ display: none !important; } .--puter-menubar .dropdown-item-icon-active { display:none; } .--puter-menubar .dropdown-item:hover .dropdown-item-icon{ display: none; } .--puter-menubar .dropdown-item:hover .dropdown-item-icon-active{ display: inline-block; } .--puter-menubar .dropdown-item-hide-icon .dropdown-item-icon, .--puter-menubar .dropdown-item-hide-icon .dropdown-item-icon-active{ display: none !important; } .--puter-menubar .dropdown-item a { color: #333; text-decoration: none; } .--puter-menubar .dropdown-item:hover, .--puter-menubar .dropdown-item:hover a { background-color: rgb(59 134 226); color: white; border-radius: 4px; } .--puter-menubar .dropdown-item-disabled, .--puter-menubar .dropdown-item-disabled:hover { opacity: 0.5; background-color: transparent; color: initial; cursor: initial; pointer-events: none; } .--puter-menubar .menubar * { user-select: none; } `; let head = document.head || document.getElementsByTagName('head')[0]; head.appendChild(style); document.addEventListener('click', function(e){ // Don't hide if clicking on disabled item if(e.target.classList.contains('dropdown-item-disabled')) return false; // Hide open menus if(!(e.target).classList.contains('menubar-item')){ document.querySelectorAll('.menubar-item.menubar-item-open').forEach(function(el) { el.classList.remove('menubar-item-open'); }) document.querySelectorAll('.dropdown').forEach(el => el.style.display = "none"); } }); // When focus is gone from this window, hide open menus window.addEventListener('blur', function(e){ document.querySelectorAll('.dropdown').forEach(function(el) { el.style.display = "none"; }) document.querySelectorAll('.menubar-item.menubar-item-open').forEach(el => el.classList.remove('menubar-item-open')); }); // Returns the siblings of the element const siblings = function (e) { const siblings = []; // if no parent, return empty list if(!e.parentNode) { return siblings; } // first child of the parent node let sibling = e.parentNode.firstChild; // get all other siblings while (sibling) { if (sibling.nodeType === 1 && sibling !== e) { siblings.push(sibling); } sibling = sibling.nextSibling; } return siblings; }; // Open dropdown document.querySelectorAll('.menubar-item').forEach(el => el.addEventListener('mousedown', function(e){ // Hide all other menus document.querySelectorAll('.dropdown').forEach(function(el) { el.style.display = 'none'; }); // Remove open class from all menus, except this menu that was just clicked document.querySelectorAll('.menubar-item.menubar-item-open').forEach(function(el) { if(el != e.target) el.classList.remove('menubar-item-open'); }); // If menu is already open, close it if(this.classList.contains('menubar-item-open')){ document.querySelectorAll('.menubar-item.menubar-item-open').forEach(function(el) { el.classList.remove('menubar-item-open'); }); } // If menu is not open, open it else if(!e.target.classList.contains('dropdown-item')){ this.classList.add('menubar-item-open') // show all sibling siblings(this).forEach(function(el) { el.style.display = 'block'; }); } })); // If a menu is open, and you hover over another menu, open that menu document.querySelectorAll('.--puter-menubar .menubar-item').forEach(el => el.addEventListener('mouseover', function(e){ const open_menus = document.querySelectorAll('.menubar-item.menubar-item-open'); if(open_menus.length > 0 && open_menus[0] !== e.target){ e.target.dispatchEvent(new Event('mousedown')); } })) } on(eventName, callback) { super.on(eventName, callback); // If we already received a broadcast for this event, run the callback immediately if (this.#eventNames.includes(eventName) && this.#lastBroadcastValue.has(eventName)) { callback(this.#lastBroadcastValue.get(eventName)); } } } export default UI