Merge pull request #238 from JsSucks/context-menus

Context menus
This commit is contained in:
Alexei Stukov 2018-08-22 14:29:56 +03:00 committed by GitHub
commit 5b63667bc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 469 additions and 7 deletions

View File

@ -8,7 +8,7 @@
* LICENSE file in the root directory of this source tree.
*/
import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts, Notifications } from 'ui';
import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts, Notifications, BdContextMenu, DiscordContextMenu } from 'ui';
import BdCss from './styles/index.scss';
import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi, BdWebApi, Connectivity, Cache } from 'modules';
import { ClientLogger as Logger, ClientIPC, Utils } from 'common';
@ -28,7 +28,7 @@ class BetterDiscord {
Logger.log('main', 'BetterDiscord starting');
this._bd = {
DOM, BdUI, BdMenu, Modals, Reflection, Toasts, Notifications,
DOM, BdUI, BdMenu, Modals, Reflection, Toasts, Notifications, BdContextMenu, DiscordContextMenu,
Events, CssEditor, Globals, Settings, Database, Updater,
ModuleManager, PluginManager, ThemeManager, ExtModuleManager,
@ -108,6 +108,13 @@ class BetterDiscord {
]);
}
showDummyNotif();
DiscordContextMenu.add([
{
text: 'Hello',
onClick: () => { Toasts.info('Hello!'); }
}
]);
} catch (err) {
Logger.err('main', ['FAILED TO LOAD!', err]);
}

View File

@ -0,0 +1,190 @@
.bd-cm,
.da-contextMenu { // sass-lint:disable-line class-name-format
background: #18191c;
box-shadow: 0 0 1px rgba(0, 0, 0, .82), 0 1px 4px rgba(0, 0, 0, .1);
border-radius: 5px;
position: fixed;
width: 170px;
z-index: 1005;
user-select: none;
&.bd-cmRenderLeft,
&.da-invertChildX { // sass-lint:disable-line class-name-format
.bd-cm {
margin-left: -170px;
}
}
.bd-cm {
left: 170px;
max-height: 270px;
overflow-y: auto;
contain: layout;
flex: 1;
min-height: 1px;
margin-left: 170px;
&::-webkit-scrollbar {
height: 8px;
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: rgba(32, 34, 37, .6);
border: 2px solid transparent;
border-radius: 4px;
cursor: move;
}
&::-webkit-scrollbar-track {
background-clip: padding-box;
border-radius: 7px;
border: 2px solid transparent;
}
}
.bd-cmGroup {
&:not(:first-child) {
&:not(:empty) {
border-top: 1px solid hsla(0, 0%, 96.1%, .08);
}
}
}
.bd-cmSub {
.bd-materialDesignIcon {
position: relative;
bottom: 2px;
fill: hsla(0, 0%, 100%, .6);
display: flex;
justify-content: flex-end;
svg {
height: 20px;
transform: rotate(-90deg);
}
}
&:hover {
svg {
fill: #fff;
}
}
}
.bd-cmItem {
cursor: default;
color: hsla(0, 0%, 100%, .6);
border-radius: 5px;
box-sizing: border-box;
font-size: 13px;
font-weight: 500;
line-height: 16px;
margin: 2px 0;
overflow: hidden;
padding: 6px 9px;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
display: flex;
&.bd-cmSub {
padding: 6px 0 6px 9px;
}
.bd-cmHint {
opacity: .8;
color: hsla(0, 0%, 100%, .6);
}
span {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-grow: 1;
}
img {
height: 16px;
}
&:hover {
background: #040405;
color: #fff;
}
}
.bd-cmToggle {
align-items: center;
display: flex;
justify-content: space-between;
padding: 5px 9px;
.bd-cmLabel {
overflow: hidden;
padding-right: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.bd-cmCheckbox {
margin-left: 3px;
pointer-events: none;
align-items: center;
cursor: pointer;
display: flex;
.bd-cmCheckboxInner {
flex-shrink: 0;
height: 18px;
position: relative;
vertical-align: top;
width: 18px;
&::before,
&::after {
content: '';
}
input {
display: none;
&:checked {
+ span {
background-color: #7289da;
border-color: #7289da;
&::after {
border-color: #fff;
border-style: solid;
border-width: 0 2px 2px 0;
content: '';
display: table;
height: 10px;
left: 4px;
position: absolute;
top: 0;
transform: rotate(45deg);
width: 4px;
}
}
}
}
span {
border: 2px solid hsla(0, 0%, 100%, .2);
border-radius: 2px;
bottom: 0;
box-sizing: border-box;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: .24s;
}
}
}
}
}

View File

@ -13,3 +13,4 @@
@import './toasts';
@import './badges';
@import './notifications';
@import './contextmenu';

View File

@ -29,3 +29,7 @@
.bd-inline {
display: inline;
}
.bd-hidden {
display: none;
}

View File

@ -6,12 +6,12 @@
.bd-notificationContainer {
position: relative;
background: #202225;
background: #18191c;
width: 280px;
height: 130px;
top: 30px;
border-radius: 5px;
box-shadow: 0 0 20px #202225;
box-shadow: 0 0 20px #18191c;
.bd-notificationHeader {
height: 10px;
@ -70,6 +70,10 @@
padding: 5px;
justify-content: flex-end;
&:not(:empty) {
border-top: 1px solid hsla(0, 0%, 96.1%, .08);
}
.bd-notificationBtn {
cursor: pointer;
height: 10px;
@ -79,11 +83,10 @@
color: #aeaeae;
padding: 5px 10px;
border-radius: 3px;
background: rgba(0, 0, 0, .2);
background: transparent;
margin-left: 5px;
&:hover {
background: rgba(0, 0, 0, .3);
color: #fff;
}
}

View File

@ -12,7 +12,7 @@ import { Events, DiscordApi, Settings } from 'modules';
import { remote } from 'electron';
import DOM from './dom';
import Vue from './vue';
import { BdSettingsWrapper, BdModals, BdToasts, BdNotifications } from './components';
import { BdSettingsWrapper, BdModals, BdToasts, BdNotifications, BdContextMenu } from './components';
export default class {
@ -53,6 +53,7 @@ export default class {
DOM.createElement('div', null, 'bd-modals').appendTo(DOM.bdModals);
DOM.createElement('div', null, 'bd-toasts').appendTo(DOM.bdToasts);
DOM.createElement('div', null, 'bd-notifications').appendTo(DOM.bdNotifications);
DOM.createElement('div', null, 'bd-contextmenu').appendTo(DOM.bdContextMenu);
DOM.createElement('bd-tooltips').appendTo(DOM.bdBody);
this.toasts = new (Vue.extend(BdToasts))({
@ -71,6 +72,10 @@ export default class {
el: '#bd-notifications'
});
this.contextmenu = new (Vue.extend(BdContextMenu))({
el: '#bd-contextmenu'
});
return this.vueInstance;
}

View File

@ -0,0 +1,56 @@
/**
* BetterDiscord Context Menu Component
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
<template>
<div ref="root" class="bd-cm" :class="{'bd-cmRenderLeft': renderLeft}" v-if="activeMenu && activeMenu.menu" :style="calculatePosition()">
<CMGroup v-for="(group, index) in activeMenu.menu.groups" :items="group.items" :key="index" :closeMenu="hide" :left="left" :top="top"/>
</div>
</template>
<script>
// Imports
import { BdContextMenu } from 'ui';
import CMGroup from './contextmenu/Group.vue';
export default {
data() {
return {
activeMenu: BdContextMenu.activeMenu,
visibleSub: -1,
left: -1,
top: -1,
renderLeft: false
};
},
components: { CMGroup },
methods: {
calculatePosition() {
if (!this.activeMenu.menu.groups.length) return {};
this.mouseX = this.activeMenu.menu.x;
this.mouseY = this.activeMenu.menu.y;
const height = this.activeMenu.menu.groups.reduce((total, group) => total + group.items.length, 0) * 28;
this.top = window.innerHeight - this.mouseY - height < 0 ? this.mouseY - height : this.mouseY;
this.left = window.innerWidth - this.mouseX - 170 < 0 ? this.mouseX - 170 : this.mouseX;
this.renderLeft = (this.left + 170 * 2) > window.innerWidth;
window.addEventListener('mouseup', this.clickHide);
return { top: `${this.top}px`, left: `${this.left}px` };
},
hide() {
window.removeEventListener('mouseup', this.clickHide);
this.activeMenu.menu = null;
},
clickHide(e) {
if (!this.$refs.root) return;
if (this.$refs.root.contains(e.target)) return;
this.hide();
}
}
}
</script>

View File

@ -0,0 +1,23 @@
/**
* BetterDiscord Context Menu Button Component
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
<template>
<div class="bd-cmItem" :style="{color: item.color || ''}" @click="onClick">
<span>{{item.text}}</span>
<div class="bd-cmHint" v-if="item.hint">{{item.hint}}</div>
<img :src="item.icon" v-else-if="item.icon"/>
</div>
</template>
<script>
export default {
props: ['item', 'onClick']
}
</script>

View File

@ -0,0 +1,59 @@
/**
* BetterDiscord Context Menu Group Component
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
<template>
<div class="bd-cmGroup" ref="test">
<template v-for="(item, index) in items">
<CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" />
<CMToggle v-else-if="item.type === 'toggle'" :item="item" :onClick="() => { item.checked = item.onChange(!item.checked) }" />
<div v-else-if="item.type === 'sub'" class="bd-cmItem bd-cmSub" @mouseenter="e => subMenuMouseEnter(e, index, item)" @mouseleave="e => subMenuMouseLeave(e, index, item)">
{{item.text}}
<MiChevronDown />
<div ref="test2" class="bd-cm" v-if="index === visibleSub" :style="subStyle">
<template v-for="(item, index) in item.items">
<CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" />
<CMToggle v-else-if="item.type === 'toggle'" :item="item" :onClick="() => { item.checked = item.onChange(!item.checked) }" />
</template>
</div>
</div>
</template>
</div>
</template>
<script>
// Imports
import CMButton from './Button.vue';
import CMToggle from './Toggle.vue';
import { MiChevronDown } from '../common';
export default {
data() {
return {
visibleSub: -1,
subStyle: {}
}
},
props: ['items', 'closeMenu', 'left', 'top'],
components: { CMButton, CMToggle, MiChevronDown },
methods: {
subMenuMouseEnter(e, index, sub) {
const subHeight = sub.items.length > 9 ? 270 : sub.items.length * e.target.offsetHeight;
const top = this.top + subHeight + e.target.offsetTop > window.innerHeight ?
this.top - subHeight + e.target.offsetTop + e.target.offsetHeight :
this.top + e.target.offsetTop;
this.subStyle = { top: `${top}px`, left: `${this.left}px` };
this.visibleSub = index;
},
subMenuMouseLeave(e, index, sub) {
this.visibleSub = -1;
}
}
}
</script>

View File

@ -0,0 +1,27 @@
/**
* BetterDiscord Context Menu Toggle Component
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
<template>
<div class="bd-cmItem bd-cmToggle" @click="onClick">
<div class="bd-cmLabel">{{item.text}}</div>
<div class="bd-cmCheckbox">
<div class="bd-cmCheckboxInner">
<input type="checkbox" :checked="item.checked || item.enabled"/>
<span></span>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['item', 'onClick']
}
</script>

View File

@ -3,3 +3,4 @@ export { default as BdSettings } from './BdSettings.vue';
export { default as BdModals } from './BdModals.vue';
export { default as BdToasts } from './BdToasts.vue';
export { default as BdNotifications } from './BdNotifications.vue';
export { default as BdContextMenu } from './BdContextMenu.vue';

View File

@ -0,0 +1,84 @@
/*
* BetterDiscord Context Menus
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { ReactComponents, WebpackModules, MonkeyPatch } from 'modules';
import { VueInjector, Toasts } from 'ui';
import CMGroup from './components/contextmenu/Group.vue';
export class BdContextMenu {
/**
* Show a context menu
* @param {MouseEvent|Object} e MouseEvent or Object { x: 0, y: 0 }
* @param {Object[]} grops Groups of items to show in context menu
*/
static show(e, groups) {
const x = e.x || e.clientX;
const y = e.y || e.clientY;
this.activeMenu.menu = { x, y, groups };
}
static get activeMenu() {
return this._activeMenu || (this._activeMenu = { menu: null });
}
}
export class DiscordContextMenu {
/**
* add items to Discord context menu
* @param {any} items items to add
* @param {Function} [filter] filter function for target filtering
*/
static add(items, filter) {
if (!this.patched) this.patch();
this.menus.push({ items, filter });
}
static get menus() {
return this._menus || (this._menus = []);
}
static async patch() {
if (this.patched) return;
this.patched = true;
const self = this;
MonkeyPatch('BD:DiscordCMOCM', WebpackModules.getModuleByProps(['openContextMenu'])).instead('openContextMenu', (_, [e, fn], originalFn) => {
const overrideFn = function (...args) {
const res = fn(...args);
if (!res.hasOwnProperty('type')) return res;
if (!res.type.prototype || !res.type.prototype.render || res.type.prototype.render.__patched) return res;
MonkeyPatch('BD:DiscordCMRender', res.type.prototype).after('render', (c, a, r) => self.renderCm(c, a, r, res));
res.type.prototype.render.__patched = true;
return res;
}
return originalFn(e, overrideFn);
});
}
static renderCm(component, args, retVal, res) {
if (!retVal.props || !res.props) return;
const { target } = res.props;
const { top, left } = retVal.props.style;
if (!target || !top || !left) return;
if (!retVal.props.children) return;
if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children];
for (const menu of this.menus.filter(menu => { if (!menu.filter) return true; return menu.filter(target)})) {
retVal.props.children.push(VueInjector.createReactElement(CMGroup, {
top,
left,
closeMenu: () => WebpackModules.getModuleByProps(['closeContextMenu']).closeContextMenu(),
items: menu.items
}));
}
}
}

View File

@ -186,6 +186,7 @@ export default class DOM {
static get bdModals() { return this.getElement('bd-modals') || this.createElement('bd-modals').appendTo(this.bdBody) }
static get bdToasts() { return this.getElement('bd-toasts') || this.createElement('bd-toasts').appendTo(this.bdBody) }
static get bdNotifications() { return this.getElement('bd-notifications') || this.createElement('bd-notifications').appendTo(this.bdBody) }
static get bdContextMenu() { return this.getElement('bd-contextmenu') || this.createElement('bd-contextmenu').appendTo(this.bdBody) }
static getElement(e) {
if (e instanceof BdNode) return e.element;

View File

@ -4,6 +4,7 @@ export { default as BdMenu, BdMenuItems } from './bdmenu';
export { default as Modals } from './modals';
export { default as Toasts } from './toasts';
export { default as Notifications } from './notifications';
export * from './contextmenus';
export { default as VueInjector } from './vueinjector';
export { default as Reflection } from './reflection';