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>
This commit is contained in:
Eric Dubé 2024-04-22 20:38:16 -04:00 committed by GitHub
parent ec31007c4b
commit 331d9e7542
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 361 additions and 11 deletions

View File

@ -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 += '<p style="text-align:center; color:red;">User not found.</p>';
else if(user.unsubscribed === 1)
h += '<p style="text-align:center; color:green;">You are already unsubscribed.</p>';

View File

@ -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

View File

@ -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;
}
}

View File

@ -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.
*

View File

@ -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 });
}
}

View File

@ -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 = $(`<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
//--------------------------------------------------------
else if(event.data.msg === 'setWindowWidth' && event.data.width !== undefined){

View File

@ -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');
@ -249,6 +266,20 @@ function UIContextMenu(options){
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){

View File

@ -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="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>`;
}
// Menubar
{
h += `<div class="window-menubar">`;
h += `</div>`;
}
// 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({

View File

@ -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;
}