mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-13 03:11:46 +00:00
refactor(in-app-menu): refactor in-app-menu plugin (#1710)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -1,3 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
@ -1,3 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 17h12a1 1 0 0 1 .117 1.993L15 19H3a1 1 0 0 1-.117-1.993L3 17h12H3Zm0-6h18a1 1 0 0 1 .117 1.993L21 13H3a1 1 0 0 1-.117-1.993L3 11h18H3Zm0-6h15a1 1 0 0 1 .117 1.993L18 7H3a1 1 0 0 1-.117-1.993L3 5h15H3Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 338 B |
@ -1,3 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@ -1,3 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 546 B |
11
src/plugins/in-app-menu/constants.ts
Normal file
11
src/plugins/in-app-menu/constants.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface InAppMenuConfig {
|
||||
enabled: boolean;
|
||||
hideDOMWindowControls: boolean;
|
||||
}
|
||||
export const defaultInAppMenuConfig: InAppMenuConfig = {
|
||||
enabled:
|
||||
(typeof window !== 'undefined' &&
|
||||
!window.navigator?.userAgent?.includes('mac')) ||
|
||||
(typeof global !== 'undefined' && global.process?.platform !== 'darwin'),
|
||||
hideDOMWindowControls: false,
|
||||
};
|
||||
@ -2,24 +2,15 @@ import titlebarStyle from './titlebar.css?inline';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onMainLoad } from './main';
|
||||
import { onMenu } from './menu';
|
||||
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||
import { onConfigChange, onPlayerApiReady, onRendererLoad } from './renderer';
|
||||
import { t } from '@/i18n';
|
||||
import { defaultInAppMenuConfig } from './constants';
|
||||
|
||||
export interface InAppMenuConfig {
|
||||
enabled: boolean;
|
||||
hideDOMWindowControls: boolean;
|
||||
}
|
||||
export default createPlugin({
|
||||
name: () => t('plugins.in-app-menu.name'),
|
||||
description: () => t('plugins.in-app-menu.description'),
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled:
|
||||
(typeof window !== 'undefined' &&
|
||||
!window.navigator?.userAgent?.includes('mac')) ||
|
||||
(typeof global !== 'undefined' && global.process?.platform !== 'darwin'),
|
||||
hideDOMWindowControls: false,
|
||||
} as InAppMenuConfig,
|
||||
config: defaultInAppMenuConfig,
|
||||
stylesheets: [titlebarStyle],
|
||||
menu: onMenu,
|
||||
|
||||
@ -27,5 +18,6 @@ export default createPlugin({
|
||||
renderer: {
|
||||
start: onRendererLoad,
|
||||
onPlayerApiReady,
|
||||
onConfigChange,
|
||||
},
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@ import { register } from 'electron-localshortcut';
|
||||
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
|
||||
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from './index';
|
||||
import type { InAppMenuConfig } from './constants';
|
||||
|
||||
export const onMainLoad = ({
|
||||
window: win,
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
const Icons = {
|
||||
submenu:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><polyline points="9 6 15 12 9 18" /></svg>',
|
||||
checkbox:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg>',
|
||||
radio: {
|
||||
checked:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
|
||||
unchecked:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
|
||||
},
|
||||
};
|
||||
|
||||
export default Icons;
|
||||
@ -1,220 +0,0 @@
|
||||
import Icons from './icons';
|
||||
|
||||
import { ElementFromHtml } from '../../utils/renderer';
|
||||
|
||||
import type { MenuItem } from 'electron';
|
||||
|
||||
interface PanelOptions {
|
||||
placement?: 'bottom' | 'right';
|
||||
order?: number;
|
||||
openOnHover?: boolean;
|
||||
}
|
||||
|
||||
export const createPanel = (
|
||||
parent: HTMLElement,
|
||||
anchor: HTMLElement,
|
||||
items: MenuItem[],
|
||||
options: PanelOptions = { placement: 'bottom', order: 0, openOnHover: false },
|
||||
) => {
|
||||
const childPanels: HTMLElement[] = [];
|
||||
const panel = document.createElement('menu-panel');
|
||||
panel.style.zIndex = `${options.order}`;
|
||||
|
||||
const updateIconState = async (iconWrapper: HTMLElement, item: MenuItem) => {
|
||||
if (item.type === 'checkbox') {
|
||||
if (item.checked) iconWrapper.innerHTML = Icons.checkbox;
|
||||
else iconWrapper.innerHTML = '';
|
||||
} else if (item.type === 'radio') {
|
||||
if (item.checked) iconWrapper.innerHTML = Icons.radio.checked;
|
||||
else iconWrapper.innerHTML = Icons.radio.unchecked;
|
||||
} else {
|
||||
const iconURL =
|
||||
typeof item.icon === 'string'
|
||||
? ((await window.ipcRenderer.invoke(
|
||||
'image-path-to-data-url',
|
||||
)) as string)
|
||||
: item.icon?.toDataURL();
|
||||
|
||||
if (iconURL) iconWrapper.style.background = `url(${iconURL})`;
|
||||
}
|
||||
};
|
||||
|
||||
const radioGroups: [MenuItem, HTMLElement][] = [];
|
||||
items.map((item) => {
|
||||
if (!item.visible) return;
|
||||
if (item.type === 'separator')
|
||||
return panel.appendChild(document.createElement('menu-separator'));
|
||||
|
||||
const menu = document.createElement('menu-item');
|
||||
const iconWrapper = document.createElement('menu-icon');
|
||||
|
||||
updateIconState(iconWrapper, item);
|
||||
menu.appendChild(iconWrapper);
|
||||
menu.append(item.label);
|
||||
|
||||
if (item.sublabel) {
|
||||
menu.classList.add('badge');
|
||||
const menuBadge = document.createElement('menu-item-badge');
|
||||
menuBadge.append(item.sublabel);
|
||||
menu.append(menuBadge);
|
||||
}
|
||||
if (item.toolTip) {
|
||||
const menuTooltip = document.createElement('menu-item-tooltip');
|
||||
menuTooltip.append(item.toolTip);
|
||||
|
||||
menu.addEventListener('mouseenter', () => {
|
||||
const rect = menu.getBoundingClientRect();
|
||||
menuTooltip.style.setProperty('max-width', `${rect.width - 8}px`);
|
||||
menuTooltip.style.setProperty('--x', `${rect.left}px`);
|
||||
menuTooltip.style.setProperty('--y', `${rect.top + rect.height}px`);
|
||||
menuTooltip.classList.add('show');
|
||||
});
|
||||
menu.addEventListener('mouseleave', () => {
|
||||
menuTooltip.classList.remove('show');
|
||||
});
|
||||
parent.append(menuTooltip);
|
||||
}
|
||||
|
||||
menu.addEventListener('click', async () => {
|
||||
await window.ipcRenderer.invoke('ytmd:menu-event', item.commandId);
|
||||
const menuItem = (await window.ipcRenderer.invoke(
|
||||
'get-menu-by-id',
|
||||
item.commandId,
|
||||
)) as MenuItem | null;
|
||||
|
||||
if (menuItem) {
|
||||
updateIconState(iconWrapper, menuItem);
|
||||
|
||||
if (menuItem.type === 'radio') {
|
||||
await Promise.all(
|
||||
radioGroups.map(async ([item, iconWrapper]) => {
|
||||
if (item.commandId === menuItem.commandId) return;
|
||||
const newItem = (await window.ipcRenderer.invoke(
|
||||
'get-menu-by-id',
|
||||
item.commandId,
|
||||
)) as MenuItem | null;
|
||||
|
||||
if (newItem) updateIconState(iconWrapper, newItem);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (item.type === 'radio') {
|
||||
radioGroups.push([item, iconWrapper]);
|
||||
}
|
||||
|
||||
if (item.type === 'submenu') {
|
||||
const subMenuIcon = document.createElement('menu-icon');
|
||||
subMenuIcon.appendChild(ElementFromHtml(Icons.submenu));
|
||||
menu.appendChild(subMenuIcon);
|
||||
|
||||
const [child, , children] = createPanel(
|
||||
parent,
|
||||
menu,
|
||||
item.submenu?.items ?? [],
|
||||
{
|
||||
placement: 'right',
|
||||
order: (options?.order ?? 0) + 1,
|
||||
openOnHover: true,
|
||||
},
|
||||
);
|
||||
|
||||
childPanels.push(child);
|
||||
childPanels.push(...children);
|
||||
}
|
||||
|
||||
return panel.appendChild(menu);
|
||||
});
|
||||
|
||||
/* methods */
|
||||
const isOpened = () => panel.getAttribute('open') === 'true';
|
||||
const close = () => panel.setAttribute('open', 'false');
|
||||
const open = () => {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
|
||||
if (options.placement === 'bottom') {
|
||||
panel.style.setProperty('--x', `${rect.x}px`);
|
||||
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
|
||||
} else {
|
||||
panel.style.setProperty('--x', `${rect.x + rect.width}px`);
|
||||
panel.style.setProperty('--y', `${rect.y}px`);
|
||||
}
|
||||
|
||||
panel.setAttribute('open', 'true');
|
||||
|
||||
// Children are placed below their parent item, which can cause
|
||||
// long lists to squeeze their children at the bottom of the screen
|
||||
// (This needs to be done *after* setAttribute)
|
||||
panel.classList.remove('position-by-bottom');
|
||||
if (
|
||||
options.placement === 'right' &&
|
||||
panel.scrollHeight > panel.clientHeight
|
||||
) {
|
||||
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
|
||||
panel.classList.add('position-by-bottom');
|
||||
}
|
||||
};
|
||||
|
||||
if (options.openOnHover) {
|
||||
let timeout: number | null = null;
|
||||
anchor.addEventListener('mouseenter', () => {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
timeout = window.setTimeout(() => {
|
||||
if (!isOpened()) open();
|
||||
}, 225);
|
||||
});
|
||||
anchor.addEventListener('mouseleave', () => {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
let mouseX = 0, mouseY = 0;
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
timeout = window.setTimeout(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
const now = document.elementFromPoint(mouseX, mouseY);
|
||||
if (now === panel || panel.contains(now)) {
|
||||
const onLeave = () => {
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
timeout = window.setTimeout(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
const now = document.elementFromPoint(mouseX, mouseY);
|
||||
if (now === panel || panel.contains(now) || childPanels.some((it) => it.contains(now))) return;
|
||||
|
||||
if (isOpened()) close();
|
||||
panel.removeEventListener('mouseleave', onLeave);
|
||||
}, 225);
|
||||
};
|
||||
panel.addEventListener('mouseleave', onLeave);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpened()) close();
|
||||
}, 225);
|
||||
});
|
||||
}
|
||||
|
||||
anchor.addEventListener('click', () => {
|
||||
if (isOpened()) close();
|
||||
else open();
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', (event) => {
|
||||
const path = event.composedPath();
|
||||
const isInside = path.some(
|
||||
(it) =>
|
||||
it === panel ||
|
||||
it === anchor ||
|
||||
childPanels.includes(it as HTMLElement),
|
||||
);
|
||||
|
||||
if (!isInside) close();
|
||||
});
|
||||
|
||||
parent.appendChild(panel);
|
||||
|
||||
return [panel, { isOpened, close, open }, childPanels] as const;
|
||||
};
|
||||
@ -1,217 +0,0 @@
|
||||
import { createPanel } from './menu/panel';
|
||||
|
||||
import logoRaw from './assets/menu.svg?inline';
|
||||
import closeRaw from './assets/close.svg?inline';
|
||||
import minimizeRaw from './assets/minimize.svg?inline';
|
||||
import maximizeRaw from './assets/maximize.svg?inline';
|
||||
import unmaximizeRaw from './assets/unmaximize.svg?inline';
|
||||
|
||||
import type { Menu } from 'electron';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from '@/plugins/in-app-menu/index';
|
||||
|
||||
const isMacOS = navigator.userAgent.includes('Macintosh');
|
||||
const isNotWindowsOrMacOS =
|
||||
!navigator.userAgent.includes('Windows') && !isMacOS;
|
||||
|
||||
export const onRendererLoad = async ({
|
||||
getConfig,
|
||||
ipc: { invoke, on },
|
||||
}: RendererContext<InAppMenuConfig>) => {
|
||||
const config = await getConfig();
|
||||
|
||||
const hideDOMWindowControls = config.hideDOMWindowControls;
|
||||
|
||||
let hideMenu = window.mainConfig.get('options.hideMenu');
|
||||
const titleBar = document.createElement('title-bar');
|
||||
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
|
||||
let maximizeButton: HTMLButtonElement;
|
||||
let panelClosers: (() => void)[] = [];
|
||||
if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
|
||||
|
||||
const logo = document.createElement('img');
|
||||
const close = document.createElement('img');
|
||||
const minimize = document.createElement('img');
|
||||
const maximize = document.createElement('img');
|
||||
const unmaximize = document.createElement('img');
|
||||
|
||||
if (window.ELECTRON_RENDERER_URL) {
|
||||
logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw;
|
||||
close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw;
|
||||
minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw;
|
||||
maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw;
|
||||
unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw;
|
||||
} else {
|
||||
logo.src = logoRaw;
|
||||
close.src = closeRaw;
|
||||
minimize.src = minimizeRaw;
|
||||
maximize.src = maximizeRaw;
|
||||
unmaximize.src = unmaximizeRaw;
|
||||
}
|
||||
|
||||
logo.classList.add('title-bar-icon');
|
||||
const logoClick = () => {
|
||||
hideMenu = !hideMenu;
|
||||
let visibilityStyle: string;
|
||||
if (hideMenu) {
|
||||
visibilityStyle = 'hidden';
|
||||
} else {
|
||||
visibilityStyle = 'visible';
|
||||
}
|
||||
const menus = document.querySelectorAll<HTMLElement>('menu-button');
|
||||
menus.forEach((menu) => {
|
||||
menu.style.visibility = visibilityStyle;
|
||||
});
|
||||
};
|
||||
logo.onclick = logoClick;
|
||||
|
||||
on('toggle-in-app-menu', logoClick);
|
||||
|
||||
if (!isMacOS) titleBar.appendChild(logo);
|
||||
document.body.appendChild(titleBar);
|
||||
|
||||
const addWindowControls = async () => {
|
||||
// Create window control buttons
|
||||
const minimizeButton = document.createElement('button');
|
||||
minimizeButton.classList.add('window-control');
|
||||
minimizeButton.appendChild(minimize);
|
||||
minimizeButton.onclick = () => invoke('window-minimize');
|
||||
|
||||
maximizeButton = document.createElement('button');
|
||||
if (await invoke('window-is-maximized')) {
|
||||
maximizeButton.classList.add('window-control');
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
} else {
|
||||
maximizeButton.classList.add('window-control');
|
||||
maximizeButton.appendChild(maximize);
|
||||
}
|
||||
maximizeButton.onclick = async () => {
|
||||
if (await invoke('window-is-maximized')) {
|
||||
// change icon to maximize
|
||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||
maximizeButton.appendChild(maximize);
|
||||
|
||||
// call unmaximize
|
||||
await invoke('window-unmaximize');
|
||||
} else {
|
||||
// change icon to unmaximize
|
||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
|
||||
// call maximize
|
||||
await invoke('window-maximize');
|
||||
}
|
||||
};
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.classList.add('window-control');
|
||||
closeButton.appendChild(close);
|
||||
closeButton.onclick = () => invoke('window-close');
|
||||
|
||||
// Create a container div for the window control buttons
|
||||
const windowControlsContainer = document.createElement('div');
|
||||
windowControlsContainer.classList.add('window-controls-container');
|
||||
windowControlsContainer.appendChild(minimizeButton);
|
||||
windowControlsContainer.appendChild(maximizeButton);
|
||||
windowControlsContainer.appendChild(closeButton);
|
||||
|
||||
// Add window control buttons to the title bar
|
||||
titleBar.appendChild(windowControlsContainer);
|
||||
};
|
||||
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls();
|
||||
|
||||
if (navBar) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach(() => {
|
||||
titleBar.style.setProperty(
|
||||
'--titlebar-background-color',
|
||||
navBar.style.backgroundColor,
|
||||
);
|
||||
document
|
||||
.querySelector('html')!
|
||||
.style.setProperty(
|
||||
'--titlebar-background-color',
|
||||
navBar.style.backgroundColor,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(navBar, { attributes: true, attributeFilter: ['style'] });
|
||||
}
|
||||
|
||||
const updateMenu = async () => {
|
||||
const children = [...titleBar.children];
|
||||
children.forEach((child) => {
|
||||
if (child !== logo) child.remove();
|
||||
});
|
||||
panelClosers = [];
|
||||
|
||||
const menu = (await invoke('get-menu')) as Menu | null;
|
||||
if (!menu) return;
|
||||
|
||||
menu.items.forEach((menuItem) => {
|
||||
const menu = document.createElement('menu-button');
|
||||
const [, { close: closer }] = createPanel(
|
||||
titleBar,
|
||||
menu,
|
||||
menuItem.submenu?.items ?? [],
|
||||
);
|
||||
panelClosers.push(closer);
|
||||
|
||||
menu.append(menuItem.label);
|
||||
titleBar.appendChild(menu);
|
||||
if (hideMenu) {
|
||||
menu.style.visibility = 'hidden';
|
||||
}
|
||||
});
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls)
|
||||
await addWindowControls();
|
||||
};
|
||||
await updateMenu();
|
||||
|
||||
document.title = 'Youtube Music';
|
||||
|
||||
on('close-all-in-app-menu-panel', () => {
|
||||
panelClosers.forEach((closer) => closer());
|
||||
});
|
||||
on('refresh-in-app-menu', () => updateMenu());
|
||||
on('window-maximize', () => {
|
||||
if (
|
||||
isNotWindowsOrMacOS &&
|
||||
!hideDOMWindowControls &&
|
||||
maximizeButton.firstChild
|
||||
) {
|
||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
}
|
||||
});
|
||||
on('window-unmaximize', () => {
|
||||
if (
|
||||
isNotWindowsOrMacOS &&
|
||||
!hideDOMWindowControls &&
|
||||
maximizeButton.firstChild
|
||||
) {
|
||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
}
|
||||
});
|
||||
|
||||
if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
|
||||
on('pip-toggle', () => {
|
||||
updateMenu();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const onPlayerApiReady = () => {
|
||||
const htmlHeadStyle = document.querySelector('head > div > style');
|
||||
if (htmlHeadStyle) {
|
||||
// HACK: This is a hack to remove the scrollbar width
|
||||
htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace(
|
||||
'html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);',
|
||||
'html::-webkit-scrollbar {',
|
||||
);
|
||||
}
|
||||
};
|
||||
57
src/plugins/in-app-menu/renderer.tsx
Normal file
57
src/plugins/in-app-menu/renderer.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
import { TitleBar } from './renderer/TitleBar';
|
||||
import { defaultInAppMenuConfig, InAppMenuConfig } from './constants';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
|
||||
const scrollStyle = `
|
||||
html::-webkit-scrollbar {
|
||||
background-color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
const isMacOS = navigator.userAgent.includes('Macintosh');
|
||||
const isNotWindowsOrMacOS =
|
||||
!navigator.userAgent.includes('Windows') && !isMacOS;
|
||||
|
||||
|
||||
const [config, setConfig] = createSignal<InAppMenuConfig>(defaultInAppMenuConfig);
|
||||
export const onRendererLoad = async ({
|
||||
getConfig,
|
||||
ipc,
|
||||
}: RendererContext<InAppMenuConfig>) => {
|
||||
setConfig(await getConfig());
|
||||
|
||||
document.title = 'YouTube Music';
|
||||
const stylesheet = new CSSStyleSheet();
|
||||
stylesheet.replaceSync(scrollStyle);
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
|
||||
|
||||
render(() => (
|
||||
<TitleBar
|
||||
ipc={ipc}
|
||||
isMacOS={isMacOS}
|
||||
enableController={isNotWindowsOrMacOS && !config().hideDOMWindowControls}
|
||||
initialCollapsed={window.mainConfig.get('options.hideMenu')}
|
||||
/>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
export const onPlayerApiReady = () => {
|
||||
// NOT WORKING AFTER YTM UPDATE (last checked 2024-02-04)
|
||||
//
|
||||
// const htmlHeadStyle = document.querySelector('head > div > style');
|
||||
// if (htmlHeadStyle) {
|
||||
// // HACK: This is a hack to remove the scrollbar width
|
||||
// htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace(
|
||||
// 'html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);',
|
||||
// 'html::-webkit-scrollbar { width: 0;',
|
||||
// );
|
||||
// }
|
||||
};
|
||||
|
||||
export const onConfigChange = (newConfig: InAppMenuConfig) => {
|
||||
setConfig(newConfig);
|
||||
};
|
||||
40
src/plugins/in-app-menu/renderer/IconButton.tsx
Normal file
40
src/plugins/in-app-menu/renderer/IconButton.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { JSX } from 'solid-js';
|
||||
import { css } from 'solid-styled-components';
|
||||
|
||||
const iconButton = css`
|
||||
background: transparent;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: white;
|
||||
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
scale: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
type CollapseIconButtonProps = JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
export const IconButton = (props: CollapseIconButtonProps) => {
|
||||
return (
|
||||
<button {...props} class={iconButton}>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
42
src/plugins/in-app-menu/renderer/MenuButton.tsx
Normal file
42
src/plugins/in-app-menu/renderer/MenuButton.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { JSX, splitProps } from 'solid-js';
|
||||
import { css } from 'solid-styled-components';
|
||||
|
||||
const menuStyle = css`
|
||||
-webkit-app-region: none;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
&:active {
|
||||
scale: 0.9;
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
export type MenuButtonProps = JSX.HTMLAttributes<HTMLLIElement> & {
|
||||
text?: string;
|
||||
selected?: boolean;
|
||||
};
|
||||
export const MenuButton = (props: MenuButtonProps) => {
|
||||
const [local, leftProps] = splitProps(props, ['text']);
|
||||
|
||||
return (
|
||||
<li {...leftProps} class={menuStyle} data-selected={props.selected}>
|
||||
{local.text}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
148
src/plugins/in-app-menu/renderer/Panel.tsx
Normal file
148
src/plugins/in-app-menu/renderer/Panel.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { createSignal, JSX, Show, splitProps } from 'solid-js';
|
||||
import { mergeProps, Portal } from 'solid-js/web';
|
||||
import { css } from 'solid-styled-components';
|
||||
import { Transition } from 'solid-transition-group';
|
||||
import { autoUpdate, flip, offset, OffsetOptions, size } from '@floating-ui/dom';
|
||||
import { useFloating } from 'solid-floating-ui';
|
||||
|
||||
const panelStyle = css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
|
||||
max-width: var(--max-width, 100%);
|
||||
max-height: var(--max-height, 100%);
|
||||
|
||||
z-index: 10000;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--titlebar-background-color, #030303) 50%,
|
||||
rgba(0, 0, 0, 0.1)
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
|
||||
transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
|
||||
`;
|
||||
|
||||
const animationStyle = {
|
||||
enter: css`
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
enterActive: css`
|
||||
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
`,
|
||||
exitTo: css`
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
exitActive: css`
|
||||
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
`,
|
||||
};
|
||||
|
||||
export type Placement =
|
||||
'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left-start'
|
||||
| 'left-end';
|
||||
export type PanelProps = JSX.HTMLAttributes<HTMLUListElement> & {
|
||||
open?: boolean;
|
||||
anchor?: HTMLElement | null;
|
||||
children: JSX.Element;
|
||||
|
||||
placement?: Placement;
|
||||
offset?: OffsetOptions;
|
||||
};
|
||||
export const Panel = (props: PanelProps) => {
|
||||
const [elements, local, leftProps] = splitProps(
|
||||
mergeProps({ placement: 'bottom' }, props),
|
||||
['anchor', 'children'],
|
||||
['open', 'placement', 'offset'],
|
||||
);
|
||||
|
||||
const [panel, setPanel] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
const position = useFloating(() => elements.anchor, panel, {
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: local.placement as Placement,
|
||||
strategy: 'fixed',
|
||||
middleware: [
|
||||
offset(local.offset),
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ elements, availableWidth, availableHeight }) {
|
||||
elements.floating.style.setProperty('--max-width', `${Math.max(200, availableWidth)}px`);
|
||||
elements.floating.style.setProperty('--max-height', `${Math.max(200, availableHeight)}px`);
|
||||
}
|
||||
}),
|
||||
flip({ fallbackStrategy: 'initialPlacement' }),
|
||||
],
|
||||
});
|
||||
|
||||
const originX = () => {
|
||||
if (position.placement.includes('left')) return '100%';
|
||||
if (position.placement.includes('right')) return '0';
|
||||
if (position.placement.includes('top') || position.placement.includes('bottom')) {
|
||||
if (position.placement.includes('start')) return '0';
|
||||
if (position.placement.includes('end')) return '100%';
|
||||
}
|
||||
|
||||
return '50%';
|
||||
};
|
||||
const originY = () => {
|
||||
if (position.placement.includes('top')) return '100%';
|
||||
if (position.placement.includes('bottom')) return '0';
|
||||
if (position.placement.includes('left') || position.placement.includes('right')) {
|
||||
if (position.placement.includes('start')) return '0';
|
||||
if (position.placement.includes('end')) return '100%';
|
||||
}
|
||||
return '50%';
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Transition
|
||||
appear
|
||||
enterClass={animationStyle.enter}
|
||||
enterActiveClass={animationStyle.enterActive}
|
||||
exitToClass={animationStyle.exitTo}
|
||||
exitActiveClass={animationStyle.exitActive}
|
||||
>
|
||||
<Show when={local.open}>
|
||||
<ul
|
||||
{...leftProps}
|
||||
ref={setPanel}
|
||||
class={panelStyle}
|
||||
style={{
|
||||
'--offset-x': `${position.x}px`,
|
||||
'--offset-y': `${position.y}px`,
|
||||
'--origin-x': originX(),
|
||||
'--origin-y': originY(),
|
||||
}}
|
||||
>
|
||||
{elements.children}
|
||||
</ul>
|
||||
</Show>
|
||||
</Transition>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
331
src/plugins/in-app-menu/renderer/PanelItem.tsx
Normal file
331
src/plugins/in-app-menu/renderer/PanelItem.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
import { createSignal, Match, Show, Switch } from 'solid-js';
|
||||
import { JSX } from 'solid-js/jsx-runtime';
|
||||
import { css } from 'solid-styled-components';
|
||||
import { Portal } from 'solid-js/web';
|
||||
|
||||
import { Transition } from 'solid-transition-group';
|
||||
import { useFloating } from 'solid-floating-ui';
|
||||
import { autoUpdate, offset, size } from '@floating-ui/dom';
|
||||
|
||||
import { Panel } from './Panel';
|
||||
|
||||
const itemStyle = css`
|
||||
position: relative;
|
||||
|
||||
-webkit-app-region: none;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto minmax(32px, auto);
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
& * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
const itemIconStyle = css`
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const itemLabelStyle = css`
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const itemChipStyle = css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #f1f1f1;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const toolTipStyle = css`
|
||||
min-width: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 4px;
|
||||
|
||||
max-width: calc(var(--max-width, 100%) - 8px);
|
||||
max-height: calc(var(--max-height, 100%) - 8px);
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: rgba(25, 25, 25, 0.8);
|
||||
color: #f1f1f1;
|
||||
font-size: 10px;
|
||||
`;
|
||||
|
||||
const popupStyle = css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
|
||||
max-width: var(--max-width, 100%);
|
||||
max-height: var(--max-height, 100%);
|
||||
|
||||
z-index: 100000000;
|
||||
pointer-events: none;
|
||||
|
||||
`;
|
||||
const animationStyle = {
|
||||
enter: css`
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
enterActive: css`
|
||||
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
`,
|
||||
exitTo: css`
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
exitActive: css`
|
||||
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
`,
|
||||
};
|
||||
|
||||
const getParents = (element: Element | null): (HTMLElement | null)[] => {
|
||||
const parents: (HTMLElement | null)[] = [];
|
||||
let now = element;
|
||||
|
||||
while (now) {
|
||||
parents.push(now as HTMLElement | null);
|
||||
now = now.parentElement;
|
||||
}
|
||||
|
||||
return parents;
|
||||
};
|
||||
|
||||
type BasePanelItemProps = {
|
||||
name: string;
|
||||
label?: string;
|
||||
chip?: string;
|
||||
toolTip?: string;
|
||||
commandId?: number;
|
||||
};
|
||||
type NormalPanelItemProps = BasePanelItemProps & {
|
||||
type: 'normal';
|
||||
onClick?: () => void;
|
||||
};
|
||||
type SubmenuItemProps = BasePanelItemProps & {
|
||||
type: 'submenu';
|
||||
level: number[];
|
||||
children: JSX.Element;
|
||||
};
|
||||
type RadioPanelItemProps = BasePanelItemProps & {
|
||||
type: 'radio';
|
||||
checked: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
};
|
||||
type CheckboxPanelItemProps = BasePanelItemProps & {
|
||||
type: 'checkbox';
|
||||
checked: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
};
|
||||
export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps;
|
||||
export const PanelItem = (props: PanelItemProps) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [toolTipOpen, setToolTipOpen] = createSignal(false);
|
||||
const [toolTip, setToolTip] = createSignal<HTMLElement | null>(null);
|
||||
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
||||
const [child, setChild] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
const position = useFloating(anchor, toolTip, {
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: 'bottom-start',
|
||||
strategy: 'fixed',
|
||||
middleware: [
|
||||
offset({ mainAxis: 8 }),
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.setProperty('--max-width', `${rects.reference.width}px`);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const handleHover = (event: MouseEvent) => {
|
||||
setToolTipOpen(true);
|
||||
event.target?.addEventListener('mouseleave', () => {
|
||||
setToolTipOpen(false);
|
||||
}, { once: true });
|
||||
|
||||
if (props.type === 'submenu') {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(true);
|
||||
|
||||
let mouseX = event.clientX;
|
||||
let mouseY = event.clientY;
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
event.target?.addEventListener('mouseleave', () => {
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
const parents = getParents(document.elementFromPoint(mouseX, mouseY));
|
||||
|
||||
if (!parents.includes(child())) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
const onOtherHover = (event: MouseEvent) => {
|
||||
const parents = getParents(event.target as HTMLElement);
|
||||
const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? '';
|
||||
const path = event.composedPath();
|
||||
|
||||
const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle));
|
||||
const isChild = closestLevel.startsWith(props.level.join('/'));
|
||||
|
||||
if (isOtherItem && !isChild) {
|
||||
setOpen(false);
|
||||
document.removeEventListener('mousemove', onOtherHover);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', onOtherHover);
|
||||
}
|
||||
}, 225);
|
||||
}, { once: true });
|
||||
}, 225);
|
||||
|
||||
event.target?.addEventListener('mouseleave', () => {
|
||||
clearTimeout(timer);
|
||||
}, { once: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
await window.ipcRenderer.invoke('ytmd:menu-event', props.commandId);
|
||||
if (props.type === 'radio') {
|
||||
props.onChange?.(!props.checked);
|
||||
} else if (props.type === 'checkbox') {
|
||||
props.onChange?.(!props.checked);
|
||||
} else if (props.type === 'normal') {
|
||||
props.onClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setAnchor}
|
||||
class={itemStyle}
|
||||
onMouseEnter={handleHover}
|
||||
onClick={handleClick}
|
||||
data-selected={open()}
|
||||
>
|
||||
<Switch fallback={<div class={itemIconStyle}/>}>
|
||||
<Match when={props.type === 'checkbox' && props.checked}>
|
||||
<svg class={itemIconStyle} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M5 12l5 5l10 -10"/>
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.type === 'radio' && props.checked}>
|
||||
<svg class={itemIconStyle} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style={{ padding: '6px' }}>
|
||||
<path fill="currentColor"
|
||||
d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"/>
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.type === 'radio' && !props.checked}>
|
||||
<svg class={itemIconStyle} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style={{ padding: '6px' }}>
|
||||
<path fill="currentColor"
|
||||
d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"/>
|
||||
</svg>
|
||||
</Match>
|
||||
</Switch>
|
||||
<span class={itemLabelStyle}>
|
||||
{props.name}
|
||||
</span>
|
||||
<Show when={props.chip} fallback={<div/>}>
|
||||
<span class={itemChipStyle}>
|
||||
{props.chip}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.type === 'submenu'}>
|
||||
<svg class={itemIconStyle} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<polyline points="9 6 15 12 9 18"/>
|
||||
</svg>
|
||||
<Panel
|
||||
ref={setChild}
|
||||
open={open()}
|
||||
anchor={anchor()}
|
||||
placement={'right-start'}
|
||||
data-level={props.type === 'submenu' && props.level.join('/')}
|
||||
offset={{ mainAxis: 8 }}
|
||||
>
|
||||
{props.type === 'submenu' && props.children}
|
||||
</Panel>
|
||||
</Show>
|
||||
<Show when={props.toolTip}>
|
||||
<Portal>
|
||||
<div
|
||||
ref={setToolTip}
|
||||
class={popupStyle}
|
||||
style={{
|
||||
'--offset-x': `${position.x}px`,
|
||||
'--offset-y': `${position.y}px`,
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
appear
|
||||
enterClass={animationStyle.enter}
|
||||
enterActiveClass={animationStyle.enterActive}
|
||||
exitToClass={animationStyle.exitTo}
|
||||
exitActiveClass={animationStyle.exitActive}
|
||||
>
|
||||
<Show when={toolTipOpen()}>
|
||||
<div class={toolTipStyle}>
|
||||
{props.toolTip}
|
||||
</div>
|
||||
</Show>
|
||||
</Transition>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
342
src/plugins/in-app-menu/renderer/TitleBar.tsx
Normal file
342
src/plugins/in-app-menu/renderer/TitleBar.tsx
Normal file
@ -0,0 +1,342 @@
|
||||
import { Menu, MenuItem } from 'electron';
|
||||
import { createEffect, createResource, createSignal, Index, Match, onMount, Show, Switch } from 'solid-js';
|
||||
import { css } from 'solid-styled-components';
|
||||
import { TransitionGroup } from 'solid-transition-group';
|
||||
|
||||
import { MenuButton } from './MenuButton';
|
||||
import { Panel } from './Panel';
|
||||
import { PanelItem } from './PanelItem';
|
||||
import { IconButton } from './IconButton';
|
||||
import { WindowController } from './WindowController';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from '../constants';
|
||||
|
||||
const titleStyle = css`
|
||||
-webkit-app-region: drag;
|
||||
box-sizing: border-box;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 10000000;
|
||||
|
||||
width: 100%;
|
||||
height: var(--menu-bar-height, 32px);
|
||||
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
color: #f1f1f1;
|
||||
font-size: 12px;
|
||||
padding: 4px 4px 4px var(--offset-left, 4px);
|
||||
background-color: var(--titlebar-background-color, #030303);
|
||||
user-select: none;
|
||||
|
||||
transition: opacity 200ms ease 0s,
|
||||
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
|
||||
|
||||
&[data-macos="true"] {
|
||||
padding: 4px 4px 4px 74px;
|
||||
}
|
||||
`;
|
||||
|
||||
const separatorStyle = css`
|
||||
min-height: 1px;
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
`;
|
||||
|
||||
const animationStyle = {
|
||||
enter: css`
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
`,
|
||||
enterActive: css`
|
||||
transition: opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1), transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
`,
|
||||
exitTo: css`
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
`,
|
||||
exitActive: css`
|
||||
transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0), transform 0.1s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
`,
|
||||
move: css`
|
||||
transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
`,
|
||||
fakeTarget: css`
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
`,
|
||||
fake: css`
|
||||
transition: all 0.00000000001s;
|
||||
`,
|
||||
};
|
||||
|
||||
export type PanelRendererProps = {
|
||||
items: Electron.Menu['items'];
|
||||
level?: number[];
|
||||
onClick?: (commandId: number, radioGroup?: MenuItem[]) => void;
|
||||
}
|
||||
const PanelRenderer = (props: PanelRendererProps) => {
|
||||
const radioGroup = () => props.items.filter((it) => it.type === 'radio');
|
||||
|
||||
return (
|
||||
<Index each={props.items}>
|
||||
{(subItem) => (
|
||||
<Show when={subItem().visible}>
|
||||
<Switch>
|
||||
<Match when={subItem().type === 'normal'}>
|
||||
<PanelItem
|
||||
type={'normal'}
|
||||
name={subItem().label}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onClick={() => props.onClick?.(subItem().commandId)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'submenu'}>
|
||||
<PanelItem
|
||||
type={'submenu'}
|
||||
name={subItem().label}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
level={[...props.level ?? [], subItem().commandId]}
|
||||
commandId={subItem().commandId}
|
||||
>
|
||||
<PanelRenderer
|
||||
items={subItem().submenu?.items ?? []}
|
||||
level={[...props.level ?? [], subItem().commandId]}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
</PanelItem>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'checkbox'}>
|
||||
<PanelItem
|
||||
type={'checkbox'}
|
||||
name={subItem().label}
|
||||
checked={subItem().checked}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onChange={() => props.onClick?.(subItem().commandId)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'radio'}>
|
||||
<PanelItem
|
||||
type={'radio'}
|
||||
name={subItem().label}
|
||||
checked={subItem().checked}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onChange={() => props.onClick?.(subItem().commandId, radioGroup())}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'separator'}>
|
||||
<hr class={separatorStyle}/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)}
|
||||
</Index>
|
||||
);
|
||||
};
|
||||
|
||||
export type TitleBarProps = {
|
||||
ipc: RendererContext<InAppMenuConfig>['ipc'];
|
||||
isMacOS?: boolean;
|
||||
enableController?: boolean;
|
||||
initialCollapsed?: boolean;
|
||||
};
|
||||
export const TitleBar = (props: TitleBarProps) => {
|
||||
const [collapsed, setCollapsed] = createSignal(props.initialCollapsed);
|
||||
const [ignoreTransition, setIgnoreTransition] = createSignal(false);
|
||||
const [openTarget, setOpenTarget] = createSignal<HTMLElement | null>(null);
|
||||
const [menu, setMenu] = createSignal<Menu | null>(null);
|
||||
|
||||
const [data, { refetch }] = createResource(async () => await props.ipc.invoke('get-menu') as Promise<Menu | null>);
|
||||
const [isMaximized, { refetch: refetchMaximize }] = createResource(async () => await props.ipc.invoke('window-is-maximized') as Promise<boolean>);
|
||||
|
||||
const handleToggleMaximize = async () => {
|
||||
if (isMaximized()) {
|
||||
await props.ipc.invoke('window-unmaximize');
|
||||
} else {
|
||||
await props.ipc.invoke('window-maximize');
|
||||
}
|
||||
await refetchMaximize();
|
||||
};
|
||||
const handleMinimize = async () => {
|
||||
await props.ipc.invoke('window-minimize');
|
||||
};
|
||||
const handleClose = async () => {
|
||||
await props.ipc.invoke('window-close');
|
||||
};
|
||||
|
||||
const refreshMenuItem = async (originalMenu: Menu, commandId: number) => {
|
||||
const menuItem = (await window.ipcRenderer.invoke(
|
||||
'get-menu-by-id',
|
||||
commandId,
|
||||
)) as MenuItem | null;
|
||||
|
||||
const newMenu = structuredClone(originalMenu);
|
||||
const stack = [...newMenu?.items ?? []];
|
||||
let now: MenuItem | undefined = stack.pop();
|
||||
while (now) {
|
||||
const index = now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ?? -1;
|
||||
|
||||
if (index >= 0) {
|
||||
if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem);
|
||||
else now?.submenu?.items?.splice(index, 1);
|
||||
}
|
||||
if (now?.submenu) {
|
||||
stack.push(...now.submenu.items);
|
||||
}
|
||||
|
||||
now = stack.pop();
|
||||
}
|
||||
|
||||
return newMenu;
|
||||
};
|
||||
|
||||
const handleItemClick = async (commandId: number, radioGroup?: MenuItem[]) => {
|
||||
const menuData = menu();
|
||||
if (!menuData) return;
|
||||
|
||||
if (Array.isArray(radioGroup)) {
|
||||
let newMenu = menuData;
|
||||
for await (const item of radioGroup) {
|
||||
newMenu = await refreshMenuItem(newMenu, item.commandId);
|
||||
}
|
||||
|
||||
setMenu(newMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
setMenu(await refreshMenuItem(menuData, commandId));
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
props.ipc.on('close-all-in-app-menu-panel', async () => {
|
||||
setIgnoreTransition(true);
|
||||
setMenu(null);
|
||||
await refetch();
|
||||
setMenu(data() ?? null);
|
||||
setIgnoreTransition(false);
|
||||
});
|
||||
props.ipc.on('refresh-in-app-menu', async () => {
|
||||
setIgnoreTransition(true);
|
||||
await refetch();
|
||||
setMenu(data() ?? null);
|
||||
setIgnoreTransition(false);
|
||||
});
|
||||
props.ipc.on('toggle-in-app-menu', () => {
|
||||
setCollapsed(!collapsed());
|
||||
});
|
||||
|
||||
props.ipc.on('window-maximize', refetchMaximize);
|
||||
props.ipc.on('window-unmaximize', refetchMaximize);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!menu() && data()) {
|
||||
setMenu(data() ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<nav class={titleStyle} data-macos={props.isMacOS}>
|
||||
<IconButton
|
||||
onClick={() => setCollapsed(!collapsed())}
|
||||
style={{
|
||||
'border-top-left-radius': '4px',
|
||||
}}
|
||||
>
|
||||
<svg width={16} height={16} viewBox={'0 0 24 24'}>
|
||||
<path
|
||||
d="M3 17h12a1 1 0 0 1 .117 1.993L15 19H3a1 1 0 0 1-.117-1.993L3 17h12H3Zm0-6h18a1 1 0 0 1 .117 1.993L21 13H3a1 1 0 0 1-.117-1.993L3 11h18H3Zm0-6h15a1 1 0 0 1 .117 1.993L18 7H3a1 1 0 0 1-.117-1.993L3 5h15H3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</IconButton>
|
||||
<TransitionGroup
|
||||
enterClass={ignoreTransition() ? animationStyle.fakeTarget : animationStyle.enter}
|
||||
enterActiveClass={ignoreTransition() ? animationStyle.fake : animationStyle.enterActive}
|
||||
exitToClass={ignoreTransition() ? animationStyle.fakeTarget : animationStyle.exitTo}
|
||||
exitActiveClass={ignoreTransition() ? animationStyle.fake : animationStyle.exitActive}
|
||||
onBeforeEnter={(element) => {
|
||||
if (ignoreTransition()) return;
|
||||
const index = Number(element.getAttribute('data-index') ?? 0);
|
||||
|
||||
(element as HTMLElement).style.setProperty('transition-delay', `${(index * 0.025)}s`);
|
||||
}}
|
||||
onAfterEnter={(element) => {
|
||||
(element as HTMLElement).style.removeProperty('transition-delay');
|
||||
}}
|
||||
onBeforeExit={(element) => {
|
||||
if (ignoreTransition()) return;
|
||||
const index = Number(element.getAttribute('data-index') ?? 0);
|
||||
const length = Number(element.getAttribute('data-length') ?? 1);
|
||||
|
||||
(element as HTMLElement).style.setProperty('transition-delay', `${(length * 0.025) - (index * 0.025)}s`);
|
||||
}}
|
||||
>
|
||||
<Show when={!collapsed()}>
|
||||
<Index each={menu()?.items}>
|
||||
{(item, index) => {
|
||||
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
if (openTarget() === anchor()) {
|
||||
setOpenTarget(null);
|
||||
} else {
|
||||
setOpenTarget(anchor());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton
|
||||
ref={setAnchor}
|
||||
text={item().label}
|
||||
onClick={handleClick}
|
||||
selected={openTarget() === anchor()}
|
||||
data-index={index}
|
||||
data-length={data()?.items.length}
|
||||
/>
|
||||
<Panel
|
||||
open={openTarget() === anchor()}
|
||||
anchor={anchor()}
|
||||
placement={'bottom-start'}
|
||||
offset={{ mainAxis: 8 }}
|
||||
>
|
||||
<PanelRenderer
|
||||
items={item().submenu?.items ?? []}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</Show>
|
||||
</TransitionGroup>
|
||||
<Show when={props.enableController}>
|
||||
<div style={{ flex: 1 }}/>
|
||||
<WindowController
|
||||
isMaximize={isMaximized()}
|
||||
onToggleMaximize={handleToggleMaximize}
|
||||
onMinimize={handleMinimize}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</Show>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
65
src/plugins/in-app-menu/renderer/WindowController.tsx
Normal file
65
src/plugins/in-app-menu/renderer/WindowController.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { css } from 'solid-styled-components';
|
||||
import { Show } from 'solid-js';
|
||||
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
const containerStyle = css`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
& > *:last-of-type {
|
||||
border-top-right-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export type WindowControllerProps = {
|
||||
isMaximize?: boolean;
|
||||
|
||||
onToggleMaximize?: () => void;
|
||||
onMinimize?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
export const WindowController = (props: WindowControllerProps) => {
|
||||
return (
|
||||
<div class={containerStyle}>
|
||||
<IconButton onClick={props.onMinimize}>
|
||||
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z"/>
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton onClick={props.onToggleMaximize}>
|
||||
<Show
|
||||
when={props.isMaximize}
|
||||
fallback={
|
||||
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
</IconButton>
|
||||
<IconButton onClick={props.onClose}>
|
||||
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z"
|
||||
/>
|
||||
</svg>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -31,7 +31,7 @@ title-bar {
|
||||
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
|
||||
}
|
||||
|
||||
menu-button {
|
||||
.menu-button {
|
||||
-webkit-app-region: none;
|
||||
|
||||
display: flex;
|
||||
@ -44,11 +44,11 @@ menu-button {
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
menu-button:hover {
|
||||
.menu-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
menu-panel {
|
||||
.menu-panel {
|
||||
position: fixed;
|
||||
top: var(--y, 0);
|
||||
left: var(--x, 0);
|
||||
@ -84,18 +84,18 @@ menu-panel {
|
||||
opacity 200ms ease 0s,
|
||||
transform 200ms ease 0s;
|
||||
}
|
||||
menu-panel[open='true'] {
|
||||
.menu-panel[open='true'] {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
menu-panel.position-by-bottom {
|
||||
.menu-panel.position-by-bottom {
|
||||
top: unset;
|
||||
bottom: calc(100vh - var(--y, 100%));
|
||||
max-height: calc(var(--y, 0) - var(--menu-bar-height, 36px) - 16px);
|
||||
}
|
||||
|
||||
menu-item {
|
||||
.menu-item {
|
||||
position: relative;
|
||||
|
||||
-webkit-app-region: none;
|
||||
@ -110,21 +110,21 @@ menu-item {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
menu-item.badge {
|
||||
.menu-item.badge {
|
||||
grid-template-columns: 32px 1fr auto minmax(32px, auto);
|
||||
}
|
||||
menu-item:hover {
|
||||
.menu-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
menu-item > menu-icon {
|
||||
.menu-item > .menu-icon {
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
menu-separator {
|
||||
.menu-separator {
|
||||
min-height: 1px;
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
@ -132,7 +132,7 @@ menu-separator {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
menu-item-badge {
|
||||
.menu-item-badge {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@ -150,7 +150,7 @@ menu-item-badge {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
menu-item-tooltip {
|
||||
.menu-item-tooltip {
|
||||
position: fixed;
|
||||
|
||||
left: var(--x, 0);
|
||||
@ -177,7 +177,7 @@ menu-item-tooltip {
|
||||
transform-origin: 50% 0;
|
||||
transition: opacity 0.225s ease-out, scale 0.225s ease-out;
|
||||
}
|
||||
menu-item-tooltip.show {
|
||||
.menu-item-tooltip.show {
|
||||
opacity: 1;
|
||||
scale: 1.0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user