From 331d9e75428ec7609394f59b1755374c7340f83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Dub=C3=A9?= Date: Mon, 22 Apr 2024 20:38:16 -0400 Subject: [PATCH] feat: allow apps to add a menubar via puter.js * Begin work on menubar and dropdowns * Improve menubar * Fix pointer event behavior * Fix labels * Fix active button * Eliminate flicker * Update _default.js --------- Co-authored-by: Nariman Jelveh --- packages/backend/src/routers/_default.js | 2 +- packages/puter-js/src/index.js | 6 +- packages/puter-js/src/lib/xdrpc.js | 111 ++++++++++++++++++++++ packages/puter-js/src/modules/UI.js | 19 +++- packages/puter-js/src/modules/Util.js | 30 ++++++ src/IPC.js | 114 +++++++++++++++++++++++ src/UI/UIContextMenu.js | 45 +++++++-- src/UI/UIWindow.js | 10 ++ src/css/style.css | 35 ++++++- 9 files changed, 361 insertions(+), 11 deletions(-) create mode 100644 packages/puter-js/src/lib/xdrpc.js create mode 100644 packages/puter-js/src/modules/Util.js diff --git a/packages/backend/src/routers/_default.js b/packages/backend/src/routers/_default.js index a475bf11..d8ef7ec4 100644 --- a/packages/backend/src/routers/_default.js +++ b/packages/backend/src/routers/_default.js @@ -176,7 +176,7 @@ router.all('*', async function(req, res, next) { const user = await get_user({uuid: req.query.user_uuid}) // more validation - if(user === undefined || user === null || user === false) + if(!user) h += '

User not found.

'; else if(user.unsubscribed === 1) h += '

You are already unsubscribed.

'; diff --git a/packages/puter-js/src/index.js b/packages/puter-js/src/index.js index 64784cf2..1cccd388 100644 --- a/packages/puter-js/src/index.js +++ b/packages/puter-js/src/index.js @@ -9,6 +9,7 @@ import Auth from './modules/Auth.js'; import FSItem from './modules/FSItem.js'; import * as utils from './lib/utils.js'; import path from './lib/path.js'; +import Util from './modules/Util.js'; window.puter = (function() { 'use strict'; @@ -168,6 +169,9 @@ window.puter = (function() { // Initialize submodules initSubmodules = function(){ + // Util + this.util = new Util(); + // Auth this.auth = new Auth(this.authToken, this.APIOrigin, this.appID, this.env); // OS @@ -175,7 +179,7 @@ window.puter = (function() { // FileSystem this.fs = new FileSystem(this.authToken, this.APIOrigin, this.appID, this.env); // UI - this.ui = new UI(this.appInstanceID, this.parentInstanceID, this.appID, this.env); + this.ui = new UI(this.appInstanceID, this.parentInstanceID, this.appID, this.env, this.util); // Hosting this.hosting = new Hosting(this.authToken, this.APIOrigin, this.appID, this.env); // Apps diff --git a/packages/puter-js/src/lib/xdrpc.js b/packages/puter-js/src/lib/xdrpc.js new file mode 100644 index 00000000..0df632e3 --- /dev/null +++ b/packages/puter-js/src/lib/xdrpc.js @@ -0,0 +1,111 @@ +/** + * This module provides a simple RPC mechanism for cross-document + * (iframe / window.postMessage) communication. + */ + +// Since `Symbol` is not clonable, we use a UUID to identify RPCs. +const $SCOPE = '9a9c83a4-7897-43a0-93b9-53217b84fde6'; + +/** + * The CallbackManager is used to manage callbacks for RPCs. + * It is used by the dehydrator and hydrator to store and retrieve + * the functions that are being called remotely. + */ +export class CallbackManager { + #messageId = 0; + + constructor () { + this.callbacks = new Map(); + } + + register_callback (callback) { + const id = this.#messageId++; + this.callbacks.set(id, callback); + return id; + } + + attach_to_source (source) { + source.addEventListener('message', event => { + const { data } = event; + console.log( + 'test-app got message from window', + data, + ); + debugger; + if (data && typeof data === 'object' && data.$SCOPE === $SCOPE) { + const { id, args } = data; + const callback = this.callbacks.get(id); + if (callback) { + callback(...args); + } + } + }); + } +} + +/** + * The dehydrator replaces functions in an object with identifiers, + * so that hydrate() can be called on the other side of the frame + * to bind RPC stubs. The original functions are stored in a map + * so that they can be called when the RPC is invoked. + */ +export class Dehydrator { + constructor ({ callbackManager }) { + this.callbackManager = callbackManager; + } + dehydrate (value) { + return this.dehydrate_value_(value); + } + dehydrate_value_ (value) { + if (typeof value === 'function') { + const id = this.callbackManager.register_callback(value); + return { $SCOPE, id }; + } else if (Array.isArray(value)) { + return value.map(this.dehydrate_value_.bind(this)); + } else if (typeof value === 'object' && value !== null) { + const result = {}; + for (const key in value) { + result[key] = this.dehydrate_value_(value[key]); + } + return result; + } else { + return value; + } + } +} + +/** + * The hydrator binds RPC stubs to the functions that were + * previously dehydrated. This allows the RPC to be invoked + * on the other side of the frame. + */ +export class Hydrator { + constructor ({ target }) { + this.target = target; + } + hydrate (value) { + return this.hydrate_value_(value); + } + hydrate_value_ (value) { + if ( + value && typeof value === 'object' && + value.$SCOPE === $SCOPE + ) { + const { id } = value; + return (...args) => { + console.log('sending message', { $SCOPE, id, args }); + console.log('target', this.target); + this.target.postMessage({ $SCOPE, id, args }, '*'); + }; + } else if (Array.isArray(value)) { + return value.map(this.hydrate_value_.bind(this)); + } else if (typeof value === 'object' && value !== null) { + const result = {}; + for (const key in value) { + result[key] = this.hydrate_value_(value[key]); + } + return result; + } + return value; + } +} diff --git a/packages/puter-js/src/modules/UI.js b/packages/puter-js/src/modules/UI.js index abfd43c2..03501313 100644 --- a/packages/puter-js/src/modules/UI.js +++ b/packages/puter-js/src/modules/UI.js @@ -149,7 +149,19 @@ class UI extends EventListener { this.#callbackFunctions[msg_id] = resolve; } - constructor (appInstanceID, parentInstanceID, appID, env) { + #postMessageWithObject = function(name, value) { + const dehydrator = this.util.rpc.getDehydrator({ + target: this.messageTarget + }); + this.messageTarget?.postMessage({ + msg: name, + env: this.env, + appInstanceID: this.appInstanceID, + value: dehydrator.dehydrate(value), + }, '*'); + } + + constructor (appInstanceID, parentInstanceID, appID, env, util) { const eventNames = [ 'localeChanged', 'themeChanged', @@ -160,6 +172,7 @@ class UI extends EventListener { this.parentInstanceID = parentInstanceID; this.appID = appID; this.env = env; + this.util = util; if(this.env === 'app'){ this.messageTarget = window.parent; @@ -641,6 +654,10 @@ class UI extends EventListener { }) } + setMenubar = function(spec) { + this.#postMessageWithObject('setMenubar', spec); + } + /** * Asynchronously extracts entries from DataTransferItems, like files and directories. * diff --git a/packages/puter-js/src/modules/Util.js b/packages/puter-js/src/modules/Util.js new file mode 100644 index 00000000..16dbe147 --- /dev/null +++ b/packages/puter-js/src/modules/Util.js @@ -0,0 +1,30 @@ +import { CallbackManager, Dehydrator, Hydrator } from "../lib/xdrpc"; + +/** + * The Util module exposes utilities within puter.js itself. + * These utilities may be used internally by other modules. + */ +export default class Util { + constructor () { + // This is in `puter.util.rpc` instead of `puter.rpc` because + // `puter.rpc` is reserved for an app-to-app RPC interface. + // This is a lower-level RPC interface used to communicate + // with iframes. + this.rpc = new UtilRPC(); + } +} + +class UtilRPC { + constructor () { + this.callbackManager = new CallbackManager(); + this.callbackManager.attach_to_source(window); + } + + getDehydrator () { + return new Dehydrator({ callbackManager: this.callbackManager }); + } + + getHydrator ({ target }) { + return new Hydrator({ target }); + } +} diff --git a/src/IPC.js b/src/IPC.js index 6e9b4cc3..a71b2149 100644 --- a/src/IPC.js +++ b/src/IPC.js @@ -27,6 +27,7 @@ import UIWindowColorPicker from './UI/UIWindowColorPicker.js'; import UIPrompt from './UI/UIPrompt.js'; import download from './helpers/download.js'; import path from "./lib/path.js"; +import UIContextMenu from './UI/UIContextMenu.js'; /** * In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI) aand each other using the postMessage API. @@ -352,6 +353,119 @@ window.addEventListener('message', async (event) => { }, '*'); } //-------------------------------------------------------- + // setMenubar + //-------------------------------------------------------- + else if(event.data.msg === 'setMenubar') { + const el_window = window_for_app_instance(event.data.appInstanceID); + + console.error(`EXPERIMENTAL: setMenubar is a work-in-progress`); + const hydrator = puter.util.rpc.getHydrator({ + target: target_iframe.contentWindow, + }); + const value = hydrator.hydrate(event.data.value); + console.log('hydrated value', value); + + // Show menubar + const $menubar = $(el_window).find('.window-menubar') + $menubar.show(); + + const sanitize_items = items => { + return items.map(item => { + return { + html: item.label, + action: item.action, + items: item.items && sanitize_items(item.items), + }; + }); + }; + + // This array will store the menubar button elements + const menubar_buttons = []; + + // Add menubar items + let current = null; + let current_i = null; + let state_open = false; + const open_menu = ({ i, pos, parent_element, items }) => { + let delay = true; + if ( state_open ) { + if ( current_i === i ) return; + + delay = false; + current && current.cancel({ meta: 'menubar', fade: false }); + } + + // Set this menubar button as active + menubar_buttons.forEach(el => el.removeClass('active')); + menubar_buttons[i].addClass('active'); + + // Open the context menu + const ctxMenu = UIContextMenu({ + delay, + parent_element, + position: {top: pos.top + 28, left: pos.left}, + items: sanitize_items(items), + }); + + state_open = true; + current = ctxMenu; + current_i = i; + + ctxMenu.onClose = (cancel_options) => { + if ( cancel_options?.meta === 'menubar' ) return; + menubar_buttons.forEach(el => el.removeClass('active')); + ctxMenu.onClose = null; + current_i = null; + current = null; + state_open = false; + } + }; + const add_items = (parent, items) => { + for (let i=0; i < items.length; i++) { + const I = i; + const item = items[i]; + const label = html_encode(item.label); + const el_item = $(`
${label}
`); + const parent_element = el_item.parent()[0]; + el_item.on('click', () => { + if ( state_open ) { + state_open = false; + current && current.cancel({ meta: 'menubar' }); + current_i = null; + current = null; + return; + } + if (item.action) { + item.action(); + } else if (item.items) { + const pos = el_item[0].getBoundingClientRect(); + open_menu({ + i, + pos, + parent_element, + items: item.items, + }); + } + }); + el_item.on('mouseover', () => { + if ( ! state_open ) return; + if ( ! item.items ) return; + + const pos = el_item[0].getBoundingClientRect(); + open_menu({ + i, + pos, + parent_element, + items: item.items, + }); + }); + $menubar.append(el_item); + menubar_buttons.push(el_item); + } + }; + add_items($menubar, value.items); + } + //-------------------------------------------------------- // setWindowWidth //-------------------------------------------------------- else if(event.data.msg === 'setWindowWidth' && event.data.width !== undefined){ diff --git a/src/UI/UIContextMenu.js b/src/UI/UIContextMenu.js index acb47ace..15065267 100644 --- a/src/UI/UIContextMenu.js +++ b/src/UI/UIContextMenu.js @@ -119,17 +119,31 @@ function UIContextMenu(options){ else y_pos = start_y; - // Show ContextMenu - $(contextMenu).delay(100).show(0) // In the right position (the mouse) - .css({ + $(contextMenu).css({ top: y_pos + "px", left: x_pos + "px" }); + // Show ContextMenu + if ( options?.delay === false ) { + $(contextMenu).show(0); + } else { + $(contextMenu).delay(100).show(0); + } // mark other context menus as inactive $('.context-menu').not(contextMenu).removeClass('context-menu-active'); + let cancel_options_ = null; + const fade_remove = () => { + $(`#context-menu-${menu_id}, .context-menu[data-element-id="${$(this).closest('.context-menu').attr('data-parent-id')}"]`).fadeOut(200, function(){ + $(contextMenu).remove(); + }); + }; + const remove = () => { + $(contextMenu).remove(); + }; + // An item is clicked $(`#context-menu-${menu_id} > li:not(.context-menu-item-disabled)`).on('click', function (e) { @@ -139,11 +153,13 @@ function UIContextMenu(options){ event.value = options.items[$(this).attr("data-action")]['val'] ?? undefined; options.items[$(this).attr("data-action")].onClick(event); } + // "action" - onClick without un-clonable pointer event + else if(options.items[$(this).attr("data-action")].action && typeof options.items[$(this).attr("data-action")].action === 'function'){ + options.items[$(this).attr("data-action")].action(); + } // close menu and, if exists, its parent if(!$(this).hasClass('context-menu-item-submenu')){ - $(`#context-menu-${menu_id}, .context-menu[data-element-id="${$(this).closest('.context-menu').attr('data-parent-id')}"]`).fadeOut(200, function(){ - $(contextMenu).remove(); - }); + fade_remove(); } return false; }); @@ -233,6 +249,7 @@ function UIContextMenu(options){ } $(contextMenu).on("remove", function () { + if ( options.onClose ) options.onClose(cancel_options_); // when removing, make parent scrollable again if(options.parent_element){ $(options.parent_element).parent().removeClass('children-have-open-contextmenu'); @@ -248,7 +265,21 @@ function UIContextMenu(options){ e.preventDefault(); e.stopPropagation(); return false; - }) + }) + + return { + cancel: (cancel_options) => { + cancel_options_ = cancel_options; + if ( cancel_options.fade === false ) { + remove(); + } else { + fade_remove(); + } + }, + set onClose (fn) { + options.onClose = fn; + } + }; } window.select_ctxmenu_item = function ($ctxmenu_item){ diff --git a/src/UI/UIWindow.js b/src/UI/UIWindow.js index dabc0a33..f3b091ff 100644 --- a/src/UI/UIWindow.js +++ b/src/UI/UIWindow.js @@ -265,6 +265,13 @@ async function UIWindow(options) { h += `
Desktop
`; h += `
Videos
`; h += ``; + + } + + // Menubar + { + h += `
`; + h += `
`; } // Navbar @@ -462,6 +469,9 @@ async function UIWindow(options) { const el_openfiledialog_open_btn = document.querySelector(`#window-${win_id} .openfiledialog-open-btn`); const el_directorypicker_select_btn = document.querySelector(`#window-${win_id} .directorypicker-select-btn`); + // disable menubar by default + $(el_window).find('.window-menubar').hide(); + if(options.is_maximized){ // save original size and position $(el_window).attr({ diff --git a/src/css/style.css b/src/css/style.css index f466d84b..a104822e 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -46,6 +46,11 @@ --taskbar-lightness: var(--primary-lightness); --taskbar-alpha: calc(0.73 * var(--primary-alpha)); --taskbar-color: var(--primary-color); + + --select-hue: 213.05; + --select-saturation: 74.22%; + --select-lightness: 55.88%; + --select-color: hsl(var(--select-hue), var(--select-saturation), var(--select-lightness)); } html, body { @@ -739,6 +744,34 @@ span.header-sort-icon img { text-shadow: none; } +.window-menubar { + display: flex; + box-sizing: border-box; + overflow: hidden; + border-bottom: 1px solid #e3e3e3; + background-color: #fafafa; + --scale: 2pt; + padding: 0 2pt; +} + +.window-menubar-item { + padding: calc(1.4 * var(--scale)) 0; + font-size: calc(5 * var(--scale)); +} + +.window-menubar-item span { + display: inline-block; + padding: calc(1.6 * var(--scale)) calc(4 * var(--scale)); + font-size: calc(5 * var(--scale)); + border-radius: calc(1.5 * var(--scale)); +} + +.window-menubar-item:hover > span, +.window-menubar-item.active > span { + background-color: var(--select-color); + color: white; +} + .explorer-empty-message { text-align: center; margin-top: 20px; @@ -1489,7 +1522,7 @@ span.header-sort-icon img { } .context-menu .context-menu-item-active { - background-color: rgb(59 134 226); + background-color: var(--select-color); color: white; border-radius: 4px; }