Procházet zdrojové kódy

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 <n.jelveh@gmail.com>
Eric Dubé před 1 rokem
rodič
revize
331d9e7542

+ 1 - 1
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})
                 const user = await get_user({uuid: req.query.user_uuid})
 
 
                 // more validation
                 // more validation
-                if(user === undefined || user === null || user === false)
+                if(!user)
                     h += '<p style="text-align:center; color:red;">User not found.</p>';
                     h += '<p style="text-align:center; color:red;">User not found.</p>';
                 else if(user.unsubscribed === 1)
                 else if(user.unsubscribed === 1)
                     h += '<p style="text-align:center; color:green;">You are already unsubscribed.</p>';
                     h += '<p style="text-align:center; color:green;">You are already unsubscribed.</p>';

+ 5 - 1
packages/puter-js/src/index.js

@@ -9,6 +9,7 @@ import Auth from './modules/Auth.js';
 import FSItem from './modules/FSItem.js';
 import FSItem from './modules/FSItem.js';
 import * as utils from './lib/utils.js';
 import * as utils from './lib/utils.js';
 import path from './lib/path.js';
 import path from './lib/path.js';
+import Util from './modules/Util.js';
 
 
 window.puter = (function() {
 window.puter = (function() {
     'use strict';
     'use strict';
@@ -168,6 +169,9 @@ window.puter = (function() {
 
 
         // Initialize submodules
         // Initialize submodules
         initSubmodules = function(){
         initSubmodules = function(){
+            // Util
+            this.util = new Util();
+
             // Auth
             // Auth
             this.auth = new Auth(this.authToken, this.APIOrigin, this.appID, this.env);
             this.auth = new Auth(this.authToken, this.APIOrigin, this.appID, this.env);
             // OS
             // OS
@@ -175,7 +179,7 @@ window.puter = (function() {
             // FileSystem
             // FileSystem
             this.fs = new FileSystem(this.authToken, this.APIOrigin, this.appID, this.env);
             this.fs = new FileSystem(this.authToken, this.APIOrigin, this.appID, this.env);
             // UI
             // 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
             // Hosting
             this.hosting = new Hosting(this.authToken, this.APIOrigin, this.appID, this.env);
             this.hosting = new Hosting(this.authToken, this.APIOrigin, this.appID, this.env);
             // Apps
             // Apps

+ 111 - 0
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;
+    }
+}

+ 18 - 1
packages/puter-js/src/modules/UI.js

@@ -149,7 +149,19 @@ class UI extends EventListener {
         this.#callbackFunctions[msg_id] = resolve;
         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 = [
         const eventNames = [
             'localeChanged',
             'localeChanged',
             'themeChanged',
             'themeChanged',
@@ -160,6 +172,7 @@ class UI extends EventListener {
         this.parentInstanceID = parentInstanceID;
         this.parentInstanceID = parentInstanceID;
         this.appID = appID;
         this.appID = appID;
         this.env = env;
         this.env = env;
+        this.util = util;
 
 
         if(this.env === 'app'){
         if(this.env === 'app'){
             this.messageTarget = window.parent;
             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.
      * Asynchronously extracts entries from DataTransferItems, like files and directories.
      * 
      * 

+ 30 - 0
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 });
+    }
+}

+ 114 - 0
src/IPC.js

@@ -27,6 +27,7 @@ import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
 import UIPrompt from './UI/UIPrompt.js';
 import UIPrompt from './UI/UIPrompt.js';
 import download from './helpers/download.js';
 import download from './helpers/download.js';
 import path from "./lib/path.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.
  * 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 = $(`<div class="window-menubar-item"><span>${label}</span></div>`);
+                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
     // setWindowWidth
     //--------------------------------------------------------
     //--------------------------------------------------------
     else if(event.data.msg === 'setWindowWidth' && event.data.width !== undefined){
     else if(event.data.msg === 'setWindowWidth' && event.data.width !== undefined){

+ 38 - 7
src/UI/UIContextMenu.js

@@ -119,17 +119,31 @@ function UIContextMenu(options){
     else
     else
         y_pos = start_y;
         y_pos = start_y;
 
 
-    // Show ContextMenu
-    $(contextMenu).delay(100).show(0)
     // In the right position (the mouse)
     // In the right position (the mouse)
-    .css({
+    $(contextMenu).css({
         top: y_pos + "px",
         top: y_pos + "px",
         left: x_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
     // mark other context menus as inactive
     $('.context-menu').not(contextMenu).removeClass('context-menu-active');
     $('.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
     // An item is clicked
     $(`#context-menu-${menu_id} > li:not(.context-menu-item-disabled)`).on('click', function (e) {
     $(`#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;
             event.value = options.items[$(this).attr("data-action")]['val'] ?? undefined;
             options.items[$(this).attr("data-action")].onClick(event);
             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
         // close menu and, if exists, its parent
         if(!$(this).hasClass('context-menu-item-submenu')){
         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;
         return false;
     });
     });
@@ -233,6 +249,7 @@ function UIContextMenu(options){
     }
     }
 
 
     $(contextMenu).on("remove", function () {
     $(contextMenu).on("remove", function () {
+        if ( options.onClose ) options.onClose(cancel_options_);
         // when removing, make parent scrollable again
         // when removing, make parent scrollable again
         if(options.parent_element){
         if(options.parent_element){
             $(options.parent_element).parent().removeClass('children-have-open-contextmenu');
             $(options.parent_element).parent().removeClass('children-have-open-contextmenu');
@@ -248,7 +265,21 @@ function UIContextMenu(options){
         e.preventDefault();
         e.preventDefault();
         e.stopPropagation();
         e.stopPropagation();
         return false;
         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){
 window.select_ctxmenu_item = function ($ctxmenu_item){

+ 10 - 0
src/UI/UIWindow.js

@@ -265,6 +265,13 @@ async function UIWindow(options) {
                 h += `<div draggable="false" title="Desktop" class="window-sidebar-item disable-user-select ${options.path === window.desktop_path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(window.desktop_path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(window.icons['folder-desktop.svg'])}">Desktop</div>`;
                 h += `<div draggable="false" title="Desktop" class="window-sidebar-item disable-user-select ${options.path === window.desktop_path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(window.desktop_path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(window.icons['folder-desktop.svg'])}">Desktop</div>`;
                 h += `<div draggable="false" title="Videos" class="window-sidebar-item disable-user-select ${options.path === window.videos_path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(window.videos_path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(window.icons['folder-videos.svg'])}">Videos</div>`;
                 h += `<div draggable="false" title="Videos" class="window-sidebar-item disable-user-select ${options.path === window.videos_path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(window.videos_path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(window.icons['folder-videos.svg'])}">Videos</div>`;
             h += `</div>`;
             h += `</div>`;
+
+        }
+
+        // Menubar
+        {
+            h += `<div class="window-menubar">`;
+            h += `</div>`;
         }
         }
 
 
         // Navbar
         // Navbar
@@ -462,6 +469,9 @@ async function UIWindow(options) {
     const el_openfiledialog_open_btn = document.querySelector(`#window-${win_id} .openfiledialog-open-btn`);
     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`);
     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){
     if(options.is_maximized){
         // save original size and position
         // save original size and position
         $(el_window).attr({
         $(el_window).attr({

+ 34 - 1
src/css/style.css

@@ -46,6 +46,11 @@
     --taskbar-lightness: var(--primary-lightness);
     --taskbar-lightness: var(--primary-lightness);
     --taskbar-alpha: calc(0.73 * var(--primary-alpha));
     --taskbar-alpha: calc(0.73 * var(--primary-alpha));
     --taskbar-color: var(--primary-color);
     --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 {
 html, body {
@@ -739,6 +744,34 @@ span.header-sort-icon img {
     text-shadow: none;
     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 {
 .explorer-empty-message {
     text-align: center;
     text-align: center;
     margin-top: 20px;
     margin-top: 20px;
@@ -1489,7 +1522,7 @@ span.header-sort-icon img {
 }
 }
 
 
 .context-menu .context-menu-item-active {
 .context-menu .context-menu-item-active {
-    background-color: rgb(59 134 226);
+    background-color: var(--select-color);
     color: white;
     color: white;
     border-radius: 4px;
     border-radius: 4px;
 }
 }