ultrawidify/src/ext/module/uwui/ClientMenu.ts

385 lines
10 KiB
TypeScript
Raw Normal View History

2025-12-29 11:32:49 +01:00
import { MenuPosition, MenuConfig, MenuItemConfig } from '@src/common/interfaces/ClientUiMenu.interface';
import extensionCss from '@src/main.css?inline';
export class ClientMenu {
2025-12-29 02:34:46 +01:00
private host!: HTMLDivElement;
private shadow!: ShadowRoot;
private _root!: HTMLDivElement;
private set root(value: HTMLDivElement) {
this._root = value;
}
public get root(): HTMLDivElement {
return this._root;
}
private trigger: HTMLDivElement;
private visible = false;
2025-12-29 11:32:49 +01:00
private menuPositionClasses: string[] = [];
2025-12-29 14:28:00 +01:00
private isHovered = false;
private isWithinActivation = false;
private lastMouseMove = performance.now();
private idleTimer?: number;
2025-12-29 14:28:00 +01:00
private onDocumentMouseMove?: (e: MouseEvent) => void;
private onDocumentMouseLeave?: () => void;
private idleIntervalId?: number;
constructor(private config: MenuConfig) {}
mount(anchorElement: HTMLElement) {
2025-12-29 11:32:49 +01:00
this.buildMenuPositionClassList();
this.createHost();
this.createShadow();
this.createMenu();
this.position(anchorElement);
this.bindGlobalMouse(anchorElement);
2025-12-29 02:34:46 +01:00
// document.documentElement.appendChild(this.host);
anchorElement.appendChild(this.host);
}
2025-12-29 14:28:00 +01:00
public destroy() {
// 1. Stop timers
if (this.idleIntervalId != null) {
clearInterval(this.idleIntervalId);
this.idleIntervalId = undefined;
}
// 2. Remove global listeners
if (this.onDocumentMouseMove) {
document.removeEventListener('mousemove', this.onDocumentMouseMove);
this.onDocumentMouseMove = undefined;
}
if (this.onDocumentMouseLeave) {
document.removeEventListener('mouseleave', this.onDocumentMouseLeave);
this.onDocumentMouseLeave = undefined;
}
// 3. Remove DOM
if (this.host && this.host.parentNode) {
this.host.parentNode.removeChild(this.host);
}
// 4. Clear references
this.shadow = undefined as any;
this.root = undefined as any;
this.host = undefined as any;
// 5. Reset state
this.visible = false;
this.isHovered = false;
this.isWithinActivation = false;
}
private getActivationRadius(anchorEl: HTMLElement): number | null {
if (this.config.ui.activation !== 'distance') {
return undefined;
}
2025-12-29 14:28:00 +01:00
if (this.config.ui.activationDistanceUnits === 'px') {
return +this.config.ui.activationDistance;
2025-12-29 14:28:00 +01:00
}
// percentage string
const rect = anchorEl.getBoundingClientRect();
const percent = +this.config.ui.activationDistance;
return Math.max(rect.width, rect.height) * (percent / 100);
2025-12-29 14:28:00 +01:00
}
2025-12-29 02:34:46 +01:00
private injectStyles() {
2025-12-29 14:28:00 +01:00
const style = document.createElement('style');
2025-12-29 02:34:46 +01:00
let css = extensionCss
.trim()
.replace(/'html, body \{/g, ':host {')
.replace(/'body \{/g, ':host {')
;
// this is bad but I can't be bothered to do it the proper way
const cssArr: string[] = css.split('@font-face');
const cssRemainder = cssArr[cssArr.length - 1].split('}').slice(1).join('}');
css = `
${cssArr[0]}
@font-face {
2025-12-29 14:28:00 +01:00
font-family: 'Heebo';
src: url(__FONT_HEEBO__) format('truetype-variations');
2025-12-29 02:34:46 +01:00
font-weight: 100 900;
font-stretch: 75% 125%;
font-style: normal;
font-display: swap;
}
@font-face {
2025-12-29 14:28:00 +01:00
font-family: 'Source Code Pro';
src: url(__FONT_SCP__) format('truetype-variations');
2025-12-29 02:34:46 +01:00
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
@font-face {
2025-12-29 14:28:00 +01:00
font-family: 'Source Code Pro';
src: url(__FONT_SCPI__) format('truetype-variations');
2025-12-29 02:34:46 +01:00
font-weight: 200 900;
font-style: italic;
font-display: swap;
}
${cssRemainder};
`;
css = css
.replace('__FONT_HEEBO__', chrome.runtime.getURL('/ui/res/fonts/Heebo.ttf'))
.replace('__FONT_SCP__', chrome.runtime.getURL('/ui/res/fonts/SourceCodePro.ttf'))
.replace('__FONT_SCPI__', chrome.runtime.getURL('/ui/res/fonts/SourceCodePro-Italic.ttf'))
.replaceAll('html,', '')
;
style.textContent = css;
this.shadow.appendChild(style);
}
private createHost() {
2025-12-29 14:28:00 +01:00
this.host = document.createElement('div');
2025-12-29 02:34:46 +01:00
this.host.classList.add('uw-ultrawidify-container-root');
Object.assign(this.host.style, {
2025-12-29 14:28:00 +01:00
position: this.config.isGlobal ? 'fixed' : 'absolute',
2025-12-29 02:34:46 +01:00
left: 0,
top: 0,
border: 0,
2025-12-29 14:28:00 +01:00
width: '100%',
height: '100%',
zIndex: this.config.isGlobal ? '2147483647' : '2147483640',
pointerEvents: 'none',
background: 'transparent',
});
}
private createShadow() {
2025-12-29 14:28:00 +01:00
this.shadow = this.host.attachShadow({ mode: 'open' });
this.injectStyles();
}
2025-12-29 11:32:49 +01:00
/**
*
* @returns
*/
private buildMenuPositionClassList() {
let classList;
switch (this.config.menuPosition) {
case MenuPosition.TopLeft:
classList = ['uw-menu-left','uw-menu-top'];
break;
case MenuPosition.Left:
classList = ['uw-menu-left','uw-menu-ycenter'];
break;
case MenuPosition.BottomLeft:
classList = ['uw-menu-left','uw-menu-bottom'];
break;
case MenuPosition.Top:
classList = ['uw-menu-center','uw-menu-top'];
break;
case MenuPosition.Bottom:
classList = ['uw-menu-center', 'uw-menu-bottom'];
break;
case MenuPosition.TopRight:
classList = ['uw-menu-right', 'uw-menu-top'];
break;
case MenuPosition.Right:
classList = ['uw-menu-right', 'uw-menu-ycenter'];
break;
case MenuPosition.BottomRight:
classList = ['uw-menu-right', 'uw-menu-bottom'];
break;
default: // left-center is our default position
classList = ['uw-menu-left', 'uw-menu-ycenter'];
break;
}
this.menuPositionClasses = classList;
}
private createMenu() {
2025-12-29 14:28:00 +01:00
this.root = document.createElement('div');
this.root.className = 'uw-menu-root uw-hidden';
2025-12-29 14:28:00 +01:00
const trigger = document.createElement('div');
trigger.classList = 'uw-menu-trigger uw-trigger';
trigger.style = `margin: ${this.config.ui.activatorPadding ?? 10} ${this.config.ui.activatorPaddingUnit ?? '%'}`;
2025-12-29 14:28:00 +01:00
trigger.textContent = 'Ultrawidify';
this.trigger = trigger;
const submenu = this.buildSubmenu(this.config.items);
2025-12-29 14:28:00 +01:00
trigger.addEventListener('mouseenter', () => this.show());
this.root.addEventListener('mouseleave', () => {
this.isHovered = false;
this.hide();
});
2025-12-29 11:32:49 +01:00
this.root.classList.add(...this.menuPositionClasses);
trigger.appendChild(submenu);
this.root.append(trigger);
this.shadow.appendChild(this.root);
2025-12-29 14:28:00 +01:00
this.root.addEventListener('mouseenter', () => {
this.isHovered = true;
this.show();
});
this.root.addEventListener('mouseleave', () => {
this.isHovered = false;
this.updateVisibility();
});
}
private buildSubmenu(items: MenuItemConfig[]): HTMLDivElement {
2025-12-29 14:28:00 +01:00
const menu = document.createElement('div');
menu.classList = 'uw-submenu';
2025-12-29 11:32:49 +01:00
menu.classList.add(...this.menuPositionClasses);
for (const item of items) {
2025-12-29 14:28:00 +01:00
const el = document.createElement('div');
if (item.customId) {
el.id = item.customId;
}
el.className = `uw-menu-item uw-trigger ${item.customClassList ?? ''}`;
if (item.customHTML) {
if (item.customHTML instanceof HTMLElement) {
el.appendChild(item.customHTML);
} else {
el.innerHTML = item.customHTML;
}
} else {
el.textContent = item.label;
}
if (item.action) {
2025-12-29 14:28:00 +01:00
el.addEventListener('click', e => {
e.stopPropagation();
item.action?.();
2025-12-29 14:28:00 +01:00
// this.hide(); // maybe dont
});
}
if (item.subitems) {
el.appendChild(this.buildSubmenu(item.subitems));
}
menu.appendChild(el);
}
return menu;
}
private position(anchorEl: HTMLElement) {
2025-12-29 11:32:49 +01:00
let appendClassList: string[] = [];
anchorEl.classList.add(...appendClassList);
}
private bindGlobalMouse(anchorEl: HTMLElement) {
const playerRect = anchorEl.getBoundingClientRect();
let menuActivatorRect, cx, cy;
2025-12-29 14:28:00 +01:00
const activationRadius = this.getActivationRadius(anchorEl);
const recalculateActivator = () => {
menuActivatorRect = this.trigger.getBoundingClientRect();
cx = menuActivatorRect.left + menuActivatorRect.width / 2;
cy = menuActivatorRect.top + menuActivatorRect.height / 2;
}
recalculateActivator();
2025-12-29 14:28:00 +01:00
this.onDocumentMouseMove = (e: MouseEvent) => {
this.lastMouseMove = performance.now();
if (activationRadius != null) {
if (! menuActivatorRect.width) {
recalculateActivator();
}
2025-12-29 14:28:00 +01:00
const d = Math.hypot(e.clientX - cx, e.clientY - cy);
this.isWithinActivation = d <= activationRadius;
2025-12-29 14:28:00 +01:00
} else {
this.isWithinActivation =
e.clientX >= playerRect.left &&
e.clientX <= playerRect.right &&
e.clientY >= playerRect.top &&
e.clientY <= playerRect.bottom &&
(this.config.ui.activation !== 'player-ctrl' || e.ctrlKey);
2025-12-29 14:28:00 +01:00
}
this.updateVisibility();
};
this.onDocumentMouseLeave = () => {
this.isHovered = false;
this.isWithinActivation = false;
this.hide();
};
document.addEventListener('mousemove', this.onDocumentMouseMove);
document.addEventListener('mouseleave', this.onDocumentMouseLeave);
this.startIdleWatcher();
}
private startIdleWatcher() {
this.idleIntervalId = window.setInterval(() => {
const idle = performance.now() - this.lastMouseMove > 1000;
if (idle && !this.isHovered) {
this.hide();
}
2025-12-29 14:28:00 +01:00
}, 200);
}
private updateVisibility() {
const idle = performance.now() - this.lastMouseMove > 1000;
if (this.isHovered) {
this.show();
return;
}
if (this.isWithinActivation && !idle) {
this.show();
return;
}
if (this.visible) {
this.hide();
}
}
private show() {
2025-12-29 14:28:00 +01:00
this.lastMouseMove = performance.now();
if (!this.visible) {
this.visible = true;
2025-12-29 14:28:00 +01:00
this.root.classList.add('uw-visible');
this.root.classList.remove('uw-hidden');
}
}
private hide() {
if (this.visible) {
this.visible = false;
2025-12-29 14:28:00 +01:00
this.root.classList.remove('uw-visible');
this.root.classList.add('uw-hidden');
}
}
}