Context menus now spawn in the currently focused windows (required for the remote channel tree viewer)
This commit is contained in:
parent
f81f3d6d3d
commit
7b120c2f57
8 changed files with 747 additions and 145 deletions
|
@ -12,7 +12,6 @@ import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
|
|||
import {ErrorCode} from "tc-shared/connection/ErrorCode";
|
||||
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
|
||||
import * as ipc from "tc-shared/ipc/BrowserIPC";
|
||||
import {kIPCAvatarChannel} from "tc-shared/file/Avatars";
|
||||
|
||||
/* TODO: Retry icon download after some time */
|
||||
/* TODO: Download icon when we're connected to the server were we want the icon from and update the icon */
|
||||
|
|
164
shared/js/ui/context-menu/Ipc.ts
Normal file
164
shared/js/ui/context-menu/Ipc.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
import * as loader from "tc-loader";
|
||||
import {Stage} from "tc-loader";
|
||||
|
||||
import {
|
||||
ContextMenuEntry,
|
||||
ContextMenuFactory,
|
||||
setGlobalContextMenuFactory
|
||||
} from "tc-shared/ui/context-menu/index";
|
||||
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
|
||||
import * as ipc from "tc-shared/ipc/BrowserIPC";
|
||||
import {Settings} from "tc-shared/settings";
|
||||
import {reactContextMenuInstance} from "tc-shared/ui/context-menu/ReactRenderer";
|
||||
import {getIconManager, RemoteIcon} from "tc-shared/file/Icons";
|
||||
|
||||
const kIPCContextMenuChannel = "context-menu";
|
||||
|
||||
class IPCContextMenu implements ContextMenuFactory {
|
||||
private readonly ipcChannel: IPCChannel;
|
||||
|
||||
private currentlyFocusedWindow: string;
|
||||
private currentWindowFocused = true;
|
||||
|
||||
private remoteContextMenuSupplierId: string;
|
||||
|
||||
private uniqueEntryId: number = 0;
|
||||
private menuCallbacks: {[key: string]: (() => void)} = {};
|
||||
private closeCallback: () => void;
|
||||
|
||||
constructor() {
|
||||
this.ipcChannel = ipc.getInstance().createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, undefined), kIPCContextMenuChannel);
|
||||
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
||||
|
||||
/* if we're just created we're the focused window ;) */
|
||||
this.currentWindowFocused = false;
|
||||
this.handleWindowFocusReceived();
|
||||
|
||||
document.addEventListener("mousedown", () => this.handleWindowFocusReceived());
|
||||
}
|
||||
|
||||
private handleWindowFocusReceived() {
|
||||
if(!this.currentWindowFocused) {
|
||||
if(this.closeCallback) {
|
||||
this.closeCallback();
|
||||
}
|
||||
this.closeCallback = undefined;
|
||||
this.menuCallbacks = {};
|
||||
this.remoteContextMenuSupplierId = undefined;
|
||||
|
||||
this.currentlyFocusedWindow = undefined;
|
||||
this.currentWindowFocused = true;
|
||||
this.ipcChannel.sendMessage("notify-focus-taken", {});
|
||||
}
|
||||
}
|
||||
|
||||
private wrapMenuEntryForRemote(entry: ContextMenuEntry) : ContextMenuEntry {
|
||||
switch (entry.type) {
|
||||
case "normal":
|
||||
if(entry.subMenu) {
|
||||
entry.subMenu = entry.subMenu.map(entry => this.wrapMenuEntryForRemote(entry));
|
||||
}
|
||||
if(entry.icon instanceof RemoteIcon) {
|
||||
entry.icon = { iconId: entry.icon.iconId, serverUniqueId: entry.icon.serverUniqueId } as any;
|
||||
}
|
||||
|
||||
/* fall through wanted! */
|
||||
case "checkbox":
|
||||
if(!entry.click) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if(!entry.uniqueId) {
|
||||
entry.uniqueId = "r_" + (++this.uniqueEntryId);
|
||||
}
|
||||
|
||||
this.menuCallbacks[entry.uniqueId] = entry.click;
|
||||
entry.click = undefined;
|
||||
return entry;
|
||||
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
private wrapMenuEntryFromRemote(entry: ContextMenuEntry) : ContextMenuEntry {
|
||||
switch (entry.type) {
|
||||
case "normal":
|
||||
if(entry.subMenu) {
|
||||
entry.subMenu = entry.subMenu.map(entry => this.wrapMenuEntryFromRemote(entry));
|
||||
}
|
||||
if(entry.icon) {
|
||||
const icon = entry.icon as any;
|
||||
entry.icon = getIconManager().resolveIcon(icon.iconId, icon.serverUniqueId);
|
||||
}
|
||||
|
||||
/* fall through wanted! */
|
||||
case "checkbox":
|
||||
if(!entry.uniqueId) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
entry.click = () => this.remoteContextMenuSupplierId && this.ipcChannel.sendMessage("notify-entry-click", { id: entry.uniqueId }, this.remoteContextMenuSupplierId);
|
||||
return entry;
|
||||
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
closeContextMenu() {
|
||||
if(this.currentWindowFocused) {
|
||||
reactContextMenuInstance.closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
spawnContextMenu(position: { pageX: number; pageY: number }, entries: ContextMenuEntry[], callbackClose?: () => void) {
|
||||
if(this.currentWindowFocused) {
|
||||
reactContextMenuInstance.spawnContextMenu(position, entries, callbackClose);
|
||||
} else {
|
||||
this.ipcChannel.sendMessage("spawn-context-menu", {
|
||||
position: position,
|
||||
entries: entries.map(entry => this.wrapMenuEntryForRemote(entry))
|
||||
});
|
||||
this.closeCallback = callbackClose;
|
||||
}
|
||||
}
|
||||
|
||||
private handleIpcMessage(remoteId: string, _broadcast: boolean, message: ChannelMessage) {
|
||||
if(message.type === "spawn-context-menu") {
|
||||
if(!this.currentWindowFocused) { return; }
|
||||
|
||||
reactContextMenuInstance.spawnContextMenu(message.data.position, message.data.entries.map(entry => this.wrapMenuEntryFromRemote(entry)), () => {
|
||||
if(!this.remoteContextMenuSupplierId) { return; }
|
||||
this.ipcChannel.sendMessage("notify-menu-close", {}, this.remoteContextMenuSupplierId);
|
||||
|
||||
this.remoteContextMenuSupplierId = undefined;
|
||||
});
|
||||
this.remoteContextMenuSupplierId = remoteId;
|
||||
} else if(message.type === "notify-focus-taken") {
|
||||
this.currentlyFocusedWindow = remoteId;
|
||||
this.currentWindowFocused = false;
|
||||
|
||||
/* close out context menu if we've any */
|
||||
reactContextMenuInstance.closeContextMenu();
|
||||
} else if(message.type === "notify-entry-click") {
|
||||
const callback = this.menuCallbacks[message.data.id];
|
||||
if(!callback) { return; }
|
||||
callback();
|
||||
} else if(message.type === "notify-menu-close") {
|
||||
this.menuCallbacks = {};
|
||||
if(this.closeCallback) {
|
||||
this.closeCallback();
|
||||
this.closeCallback = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||
priority: 80,
|
||||
name: "context menu init",
|
||||
function: async () => {
|
||||
setGlobalContextMenuFactory(new IPCContextMenu());
|
||||
}
|
||||
})
|
186
shared/js/ui/context-menu/ReactRenderer.scss
Normal file
186
shared/js/ui/context-menu/ReactRenderer.scss
Normal file
|
@ -0,0 +1,186 @@
|
|||
@import "../../../css/static/mixin";
|
||||
|
||||
.globalContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
position: static;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
overflow: visible;
|
||||
display: block;
|
||||
z-index: 120000;
|
||||
position: absolute;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
.menuContainer {
|
||||
border: 1px solid #CCC;
|
||||
white-space: nowrap;
|
||||
font-family: sans-serif;
|
||||
background: #FFF;
|
||||
color: #333;
|
||||
padding: 3px;
|
||||
|
||||
&.left {
|
||||
margin-left: -100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: Arial, serif;
|
||||
font-size: 12px;
|
||||
white-space: pre;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.label {
|
||||
/* nothing really to do here */
|
||||
|
||||
&.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
/*padding: 8px 12px;*/
|
||||
padding-right: 12px;
|
||||
cursor: pointer;
|
||||
list-style-type: none;
|
||||
transition: all .3s ease;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
|
||||
display: flex;
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
background-color: lightgray;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: #DEF;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
|
||||
display: inline-block;
|
||||
border: solid black;
|
||||
border-width: 0 .2em .2em 0;
|
||||
|
||||
transform: rotate(-45deg);
|
||||
-webkit-transform: rotate(-45deg);
|
||||
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
}
|
||||
|
||||
.subContainer {
|
||||
margin-right: -3px;
|
||||
padding-right: 24px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
> .subMenu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subMenu {
|
||||
display: none;
|
||||
left: 100%;
|
||||
top: -4px;
|
||||
position: absolute;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.shown {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-top: 1px;
|
||||
margin-left: 1px;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
|
||||
@include user-select(none);
|
||||
|
||||
/* Hide the browser's default checkbox */
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 11px;
|
||||
width: 11px;
|
||||
background-color: #eee;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 3px;
|
||||
height: 7px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
-webkit-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover input ~ .checkmark {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
input:checked ~ .checkmark {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
|
||||
input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
}
|
273
shared/js/ui/context-menu/ReactRenderer.tsx
Normal file
273
shared/js/ui/context-menu/ReactRenderer.tsx
Normal file
|
@ -0,0 +1,273 @@
|
|||
import * as loader from "tc-loader";
|
||||
import {Stage} from "tc-loader";
|
||||
import {
|
||||
closeContextMenu,
|
||||
ContextMenuEntry,
|
||||
ContextMenuEntryNormal,
|
||||
ContextMenuFactory, MenuEntryLabel,
|
||||
} from "tc-shared/ui/context-menu/index";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import {IconRenderer, RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
|
||||
import {useContext} from "react";
|
||||
|
||||
const cssStyle = require("./ReactRenderer.scss");
|
||||
const CloseCallback = React.createContext<() => void>(undefined);
|
||||
|
||||
let globalMouseListener;
|
||||
let globalContainer: HTMLDivElement;
|
||||
let refRenderer = React.createRef<ContextMenuRenderer>();
|
||||
|
||||
const MenuEntryIconRenderer = (props: { entry: ContextMenuEntryNormal }) => {
|
||||
if(!props.entry.icon || typeof props.entry.icon === "string") {
|
||||
return <IconRenderer icon={props.entry.icon as any} className={cssStyle.icon} />;
|
||||
} else {
|
||||
return <RemoteIconRenderer icon={props.entry.icon} className={cssStyle.icon} />;
|
||||
}
|
||||
};
|
||||
|
||||
const MenuLabelRenderer = (props: { label: MenuEntryLabel }) => {
|
||||
let text;
|
||||
let classes = [];
|
||||
if(typeof props.label === "string") {
|
||||
text = props.label;
|
||||
} else {
|
||||
text = props.label.text;
|
||||
if(props.label.bold) {
|
||||
classes.push(cssStyle.bold);
|
||||
}
|
||||
}
|
||||
|
||||
classes.push(cssStyle.label);
|
||||
return <div className={classes.join(" ")}>{text}</div>;
|
||||
}
|
||||
|
||||
const MenuEntryRenderer = (props: { entry: ContextMenuEntry }) => {
|
||||
const closeCallback = useContext(CloseCallback);
|
||||
const clickListener = () => {
|
||||
closeCallback();
|
||||
|
||||
if("click" in props.entry && typeof props.entry.click === "function") {
|
||||
props.entry.click();
|
||||
}
|
||||
};
|
||||
|
||||
if(typeof props.entry.visible === "boolean" && !props.entry.visible) { return null; }
|
||||
switch (props.entry.type) {
|
||||
case "separator":
|
||||
return <hr key={"hr"} />;
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div
|
||||
className={cssStyle.entry + " " + (typeof props.entry.enabled === "boolean" && !props.entry.enabled ? cssStyle.disabled : "")}
|
||||
onClick={clickListener}
|
||||
>
|
||||
<label className={cssStyle.checkbox}>
|
||||
<input type={"checkbox"} checked={props.entry.checked || false} readOnly={true} />
|
||||
<span className={cssStyle.checkmark} />
|
||||
</label>
|
||||
<MenuLabelRenderer label={props.entry.label} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "normal":
|
||||
return (
|
||||
<div
|
||||
className={cssStyle.entry + " " + (props.entry.subMenu?.length ? cssStyle.subContainer : "") + " " + (typeof props.entry.enabled === "boolean" && !props.entry.enabled ? cssStyle.disabled : "")}
|
||||
onClick={clickListener}
|
||||
>
|
||||
<MenuEntryIconRenderer entry={props.entry} />
|
||||
<MenuLabelRenderer label={props.entry.label} />
|
||||
{!props.entry.subMenu?.length ? undefined :
|
||||
<React.Fragment>
|
||||
<div className={cssStyle.arrow} />
|
||||
<MenuRenderer entries={props.entry.subMenu} subMenu={true} />
|
||||
</React.Fragment>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MenuRenderer = (props: { entries: ContextMenuEntry[], subMenu: boolean }) => {
|
||||
return (
|
||||
<div className={cssStyle.menuContainer + " " + (props.subMenu ? cssStyle.subMenu : "")}>
|
||||
{props.entries.map(entry => <MenuEntryRenderer entry={entry} key={entry.uniqueId} />)}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
class ContextMenuRenderer extends React.Component<{}, { entries: ContextMenuEntry[], pageX: number, pageY: number, callbackClose: () => void }> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
pageY: 0,
|
||||
pageX: 0,
|
||||
entries: [],
|
||||
callbackClose: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CloseCallback.Provider value={() => {
|
||||
if(this.state.callbackClose) {
|
||||
this.state.callbackClose();
|
||||
}
|
||||
this.setState({ entries: [], callbackClose: undefined });
|
||||
}}>
|
||||
<div
|
||||
className={cssStyle.container + " " + (this.state.entries.length ? cssStyle.shown : "")}
|
||||
style={{ top: this.state.pageY, left: this.state.pageX }}
|
||||
>
|
||||
<MenuRenderer entries={this.state.entries} subMenu={false} />
|
||||
</div>
|
||||
</CloseCallback.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let uniqueIdIndex = 0;
|
||||
function generateUniqueIds(entry: ContextMenuEntry) {
|
||||
if(typeof entry.uniqueId !== "string") {
|
||||
entry.uniqueId = "_" + (++uniqueIdIndex);
|
||||
}
|
||||
|
||||
if(entry.type === "normal" && entry.subMenu) {
|
||||
entry.subMenu.forEach(generateUniqueIds);
|
||||
}
|
||||
}
|
||||
|
||||
export let reactContextMenuInstance: ContextMenuFactory;
|
||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||
priority: 80,
|
||||
name: "context menu init",
|
||||
function: async () => {
|
||||
document.addEventListener("mousedown", globalMouseListener = event => {
|
||||
if(refRenderer.current?.state.entries?.length) {
|
||||
let target: HTMLElement = event.target as any;
|
||||
while (target) {
|
||||
if(target.classList.contains(cssStyle.container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
target = target.parentElement;
|
||||
}
|
||||
|
||||
closeContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
globalContainer = document.createElement("div");
|
||||
globalContainer.classList.add(cssStyle.globalContainer);
|
||||
document.body.append(globalContainer);
|
||||
|
||||
ReactDOM.render(<ContextMenuRenderer ref={refRenderer} />, globalContainer);
|
||||
|
||||
reactContextMenuInstance = new class implements ContextMenuFactory {
|
||||
spawnContextMenu(position: { pageX: number; pageY: number }, entries: ContextMenuEntry[]) {
|
||||
entries.forEach(generateUniqueIds);
|
||||
refRenderer.current?.setState({
|
||||
entries: entries,
|
||||
pageX: position.pageX,
|
||||
pageY: position.pageY
|
||||
});
|
||||
}
|
||||
|
||||
closeContextMenu() {
|
||||
if(refRenderer.current?.state.entries?.length) {
|
||||
refRenderer.current?.setState({ callbackClose: undefined, entries: [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
setTimeout(() => {
|
||||
spawnContextMenu({ pageX: 100, pageY: 100 }, [
|
||||
{
|
||||
type: "normal",
|
||||
label: { text: "test", bold: true },
|
||||
icon: ClientIcon.IsTalker
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 2",
|
||||
icon: ClientIcon.ServerGreen
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 3",
|
||||
subMenu: [
|
||||
{
|
||||
type: "checkbox",
|
||||
label: "test - cb",
|
||||
checked: false
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
label: "test - cb 1",
|
||||
checked: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 4",
|
||||
subMenu: [
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 1",
|
||||
icon: ClientIcon.IsTalker
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 2",
|
||||
icon: ClientIcon.ServerGreen
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 3"
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 4",
|
||||
subMenu: [
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 1",
|
||||
icon: ClientIcon.IsTalker
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 2",
|
||||
icon: ClientIcon.ServerGreen
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 3"
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
label: "test 4"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
}, 1000);
|
||||
*/
|
||||
}
|
||||
})
|
69
shared/js/ui/context-menu/index.ts
Normal file
69
shared/js/ui/context-menu/index.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import {RemoteIcon} from "tc-shared/file/Icons";
|
||||
import {ClientIcon} from "svg-sprites/client-icons";
|
||||
|
||||
import "./Ipc";
|
||||
|
||||
export type MenuEntryLabel = {
|
||||
text: string,
|
||||
bold?: boolean;
|
||||
} | string;
|
||||
|
||||
type MenuEntryClickable = {
|
||||
uniqueId?: string,
|
||||
label: MenuEntryLabel,
|
||||
|
||||
enabled?: boolean;
|
||||
visible?: boolean;
|
||||
|
||||
click?: () => void;
|
||||
}
|
||||
|
||||
export type ContextMenuEntryNormal = {
|
||||
type: "normal",
|
||||
icon?: RemoteIcon | ClientIcon,
|
||||
subMenu?: ContextMenuEntry[],
|
||||
} & MenuEntryClickable;
|
||||
|
||||
export type ContextMenuEntrySeparator = {
|
||||
uniqueId?: string,
|
||||
type: "separator",
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export type ContextMenuEntryCheckbox = {
|
||||
type: "checkbox",
|
||||
checked?: boolean;
|
||||
} & MenuEntryClickable;
|
||||
|
||||
export type ContextMenuEntry = ContextMenuEntryNormal | ContextMenuEntrySeparator | ContextMenuEntryCheckbox;
|
||||
|
||||
export interface ContextMenuFactory {
|
||||
spawnContextMenu(position: { pageX: number, pageY: number }, entries: ContextMenuEntry[], callbackClose?: () => void);
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
let globalContextMenuFactory: ContextMenuFactory;
|
||||
export function setGlobalContextMenuFactory(instance: ContextMenuFactory) {
|
||||
if(globalContextMenuFactory) {
|
||||
throw tr("the global context menu factory has already been set");
|
||||
}
|
||||
globalContextMenuFactory = instance;
|
||||
}
|
||||
|
||||
|
||||
export function spawnContextMenu(position: { pageX: number, pageY: number }, entries: ContextMenuEntry[], callbackClose?: () => void) {
|
||||
if(!globalContextMenuFactory) {
|
||||
throw tr("missing global context menu factory");
|
||||
}
|
||||
|
||||
globalContextMenuFactory.spawnContextMenu(position, entries, callbackClose);
|
||||
}
|
||||
|
||||
|
||||
export function closeContextMenu() {
|
||||
if(!globalContextMenuFactory) {
|
||||
throw tr("missing global context menu factory");
|
||||
}
|
||||
|
||||
globalContextMenuFactory.closeContextMenu();
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import * as $ from "jquery";
|
||||
import {closeContextMenu, ContextMenuEntry, spawnContextMenu} from "tc-shared/ui/context-menu";
|
||||
import {ClientIcon} from "svg-sprites/client-icons";
|
||||
|
||||
export interface MenuEntry {
|
||||
callback?: () => void;
|
||||
type: MenuEntryType;
|
||||
|
@ -75,153 +77,61 @@ export function set_provider(_provider: ContextMenuProvider) {
|
|||
provider.initialize();
|
||||
}
|
||||
|
||||
class HTMLContextMenuProvider implements ContextMenuProvider {
|
||||
private _global_click_listener: (event) => any;
|
||||
private _context_menu: JQuery;
|
||||
private _close_callbacks: (() => any)[] = [];
|
||||
private _visible = false;
|
||||
|
||||
class LegacyBridgeContextMenuProvider implements ContextMenuProvider {
|
||||
despawn_context_menu() {
|
||||
if(!this._visible)
|
||||
return;
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
let menu = this._context_menu || (this._context_menu = $(".context-menu"));
|
||||
menu.animate({opacity: 0}, 100, () => menu.css("display", "none"));
|
||||
this._visible = false;
|
||||
for(const callback of this._close_callbacks) {
|
||||
if(typeof(callback) !== "function") {
|
||||
console.error(tr("Given close callback is not a function!. Callback: %o"), callback);
|
||||
continue;
|
||||
}
|
||||
callback();
|
||||
finalize() { }
|
||||
initialize() { }
|
||||
|
||||
html_format_enabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static mapEntry(entry: MenuEntry, closeListener: (() => void)[]) : ContextMenuEntry | undefined {
|
||||
switch (entry.type) {
|
||||
case MenuEntryType.CLOSE:
|
||||
closeListener.push(entry.callback);
|
||||
break;
|
||||
|
||||
case MenuEntryType.CHECKBOX:
|
||||
return {
|
||||
type: "checkbox",
|
||||
checked: entry.checkbox_checked,
|
||||
label: typeof entry.name === "function" ? entry.name() : entry.name,
|
||||
click: entry.callback,
|
||||
enabled: !entry.disabled && !entry.invalidPermission,
|
||||
visible: entry.visible
|
||||
};
|
||||
|
||||
case MenuEntryType.ENTRY:
|
||||
case MenuEntryType.SUB_MENU:
|
||||
return {
|
||||
type: "normal",
|
||||
label: typeof entry.name === "function" ? entry.name() : entry.name,
|
||||
click: entry.callback,
|
||||
enabled: !entry.disabled && !entry.invalidPermission,
|
||||
visible: entry.visible,
|
||||
icon: entry.icon_class as ClientIcon,
|
||||
subMenu: entry.sub_menu ? entry.sub_menu.map(entry => this.mapEntry(entry, closeListener)).filter(e => !!e) : undefined
|
||||
};
|
||||
|
||||
case MenuEntryType.HR:
|
||||
return {
|
||||
type: "separator",
|
||||
visible: entry.visible
|
||||
};
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
this._close_callbacks = [];
|
||||
}
|
||||
|
||||
finalize() {
|
||||
$(document).unbind('click', this._global_click_listener);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this._global_click_listener = this.on_global_click.bind(this);
|
||||
$(document).bind('click', this._global_click_listener);
|
||||
}
|
||||
|
||||
private on_global_click(event) {
|
||||
//let menu = this._context_menu || (this._context_menu = $(".context-menu"));
|
||||
|
||||
if(!this._visible) return;
|
||||
if ($(event.target).parents(".context-menu").length == 0) {
|
||||
this.despawn_context_menu();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private generate_tag(entry: MenuEntry) : JQuery {
|
||||
if(entry.type == MenuEntryType.HR) {
|
||||
return $.spawn("hr");
|
||||
} else if(entry.type == MenuEntryType.ENTRY) {
|
||||
let icon = entry.icon_class;
|
||||
if(!icon || icon.length == 0) icon = "icon_empty";
|
||||
else icon = "icon " + icon;
|
||||
|
||||
let tag = $.spawn("div").addClass("entry");
|
||||
tag.append($.spawn("div").addClass(icon));
|
||||
tag.append($.spawn("div").html($.isFunction(entry.name) ? entry.name() : entry.name));
|
||||
|
||||
if(entry.disabled || entry.invalidPermission)
|
||||
tag.addClass("disabled");
|
||||
else {
|
||||
tag.on('click', () => {
|
||||
if($.isFunction(entry.callback))
|
||||
entry.callback();
|
||||
entry.callback = undefined; /* for some reason despawn_context_menu() causes a second click event? */
|
||||
this.despawn_context_menu();
|
||||
});
|
||||
}
|
||||
return tag;
|
||||
} else if(entry.type == MenuEntryType.CHECKBOX) {
|
||||
let checkbox = $.spawn("label").addClass("ccheckbox");
|
||||
$.spawn("input").attr("type", "checkbox").prop("checked", !!entry.checkbox_checked).appendTo(checkbox);
|
||||
$.spawn("span").addClass("checkmark").appendTo(checkbox);
|
||||
|
||||
let tag = $.spawn("div").addClass("entry");
|
||||
tag.append(checkbox);
|
||||
tag.append($.spawn("div").html($.isFunction(entry.name) ? entry.name() : entry.name));
|
||||
|
||||
if(entry.disabled || entry.invalidPermission)
|
||||
tag.addClass("disabled");
|
||||
else {
|
||||
tag.on('click', () => {
|
||||
if($.isFunction(entry.callback))
|
||||
entry.callback();
|
||||
entry.callback = undefined; /* for some reason despawn_context_menu() causes a second click event? */
|
||||
this.despawn_context_menu();
|
||||
});
|
||||
}
|
||||
return tag;
|
||||
} else if(entry.type == MenuEntryType.SUB_MENU) {
|
||||
let icon = entry.icon_class;
|
||||
if(!icon || icon.length == 0) icon = "icon_empty";
|
||||
else icon = "icon " + icon;
|
||||
|
||||
let tag = $.spawn("div").addClass("entry").addClass("sub-container");
|
||||
tag.append($.spawn("div").addClass(icon));
|
||||
tag.append($.spawn("div").html($.isFunction(entry.name) ? entry.name() : entry.name));
|
||||
|
||||
tag.append($.spawn("div").addClass("arrow right"));
|
||||
|
||||
if(entry.disabled || entry.invalidPermission) tag.addClass("disabled");
|
||||
else {
|
||||
let menu = $.spawn("div").addClass("sub-menu").addClass("context-menu-container");
|
||||
for(const e of entry.sub_menu) {
|
||||
if(typeof(entry.visible) === 'boolean' && !entry.visible)
|
||||
continue;
|
||||
menu.append(this.generate_tag(e));
|
||||
}
|
||||
menu.appendTo(tag);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
return $.spawn("div").text("undefined");
|
||||
}
|
||||
|
||||
spawn_context_menu(x: number, y: number, ...entries: MenuEntry[]) {
|
||||
this._visible = true;
|
||||
|
||||
let menu_tag = this._context_menu || (this._context_menu = $(".context-menu"));
|
||||
menu_tag.finish().empty().css("opacity", "0");
|
||||
|
||||
const menu_container = $.spawn("div").addClass("context-menu-container");
|
||||
this._close_callbacks = [];
|
||||
|
||||
for(const entry of entries){
|
||||
if(typeof(entry.visible) === 'boolean' && !entry.visible)
|
||||
continue;
|
||||
|
||||
if(entry.type == MenuEntryType.CLOSE) {
|
||||
if(entry.callback)
|
||||
this._close_callbacks.push(entry.callback);
|
||||
} else
|
||||
menu_container.append(this.generate_tag(entry));
|
||||
}
|
||||
|
||||
menu_tag.append(menu_container);
|
||||
menu_tag.animate({opacity: 1}, 100).css("display", "block");
|
||||
|
||||
const width = menu_container.visible_width();
|
||||
if(x + width + 5 > window.innerWidth)
|
||||
menu_container.addClass("left");
|
||||
|
||||
// In the right position (the mouse)
|
||||
menu_tag.css({
|
||||
"top": y + "px",
|
||||
"left": x + "px"
|
||||
});
|
||||
}
|
||||
|
||||
html_format_enabled(): boolean {
|
||||
return true;
|
||||
const closeCallbacks = [];
|
||||
spawnContextMenu({ pageX: x, pageY: y }, entries.map(e => LegacyBridgeContextMenuProvider.mapEntry(e, closeCallbacks)).filter(e => !!e), () => closeCallbacks.forEach(callback => callback()));
|
||||
}
|
||||
}
|
||||
set_provider(new HTMLContextMenuProvider());
|
||||
|
||||
set_provider(new LegacyBridgeContextMenuProvider());
|
|
@ -1,4 +1,4 @@
|
|||
import {ChannelMessage, getInstance as getIPCInstance, IPCChannel} from "../../../ipc/BrowserIPC";
|
||||
import {getInstance as getIPCInstance} from "../../../ipc/BrowserIPC";
|
||||
import {Settings, SettingsKey} from "../../../settings";
|
||||
import {
|
||||
Controller2PopoutMessages, EventControllerBase,
|
||||
|
|
|
@ -14,6 +14,7 @@ import {setupJSRender} from "../../../ui/jsrender";
|
|||
|
||||
import "../../../file/RemoteAvatars";
|
||||
import "../../../file/RemoteIcons";
|
||||
import "../../context-menu";
|
||||
|
||||
let modalRenderer: ModalRenderer;
|
||||
let modalInstance: AbstractModal;
|
||||
|
|
Loading…
Add table
Reference in a new issue