mirror of
https://github.com/HeyPuter/puter
synced 2024-11-14 22:06:00 +00:00
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:
parent
ec31007c4b
commit
331d9e7542
@ -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>';
|
||||
|
@ -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
|
||||
|
111
packages/puter-js/src/lib/xdrpc.js
Normal file
111
packages/puter-js/src/lib/xdrpc.js
Normal 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;
|
||||
}
|
||||
}
|
@ -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.
|
||||
*
|
||||
|
30
packages/puter-js/src/modules/Util.js
Normal file
30
packages/puter-js/src/modules/Util.js
Normal 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 });
|
||||
}
|
||||
}
|
114
src/IPC.js
114
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 = $(`<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){
|
||||
|
@ -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){
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user