2025-12-29 11:32:49 +01:00
|
|
|
import { MenuPosition, MenuConfig, MenuItemConfig } from '@src/common/interfaces/ClientUiMenu.interface';
|
2025-12-29 00:21:03 +01:00
|
|
|
import extensionCss from '@src/main.css?inline';
|
|
|
|
|
|
|
|
|
|
export class ClientMenu {
|
2025-12-29 02:34:46 +01:00
|
|
|
|
2025-12-29 00:21:03 +01:00
|
|
|
private host!: HTMLDivElement;
|
|
|
|
|
private shadow!: ShadowRoot;
|
|
|
|
|
private root!: 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;
|
|
|
|
|
|
|
|
|
|
private onDocumentMouseMove?: (e: MouseEvent) => void;
|
|
|
|
|
private onDocumentMouseLeave?: () => void;
|
|
|
|
|
private idleIntervalId?: number;
|
|
|
|
|
|
2025-12-29 00:21:03 +01:00
|
|
|
constructor(private config: MenuConfig) {}
|
|
|
|
|
|
|
|
|
|
mount(anchorElement: HTMLElement) {
|
2025-12-29 11:32:49 +01:00
|
|
|
this.buildMenuPositionClassList();
|
2025-12-29 00:21:03 +01:00
|
|
|
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.activationRadius == null) return null;
|
|
|
|
|
|
|
|
|
|
if (typeof this.config.activationRadius === 'number') {
|
|
|
|
|
return this.config.activationRadius;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// percentage string
|
|
|
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
|
|
|
const pct = parseFloat(this.config.activationRadius);
|
|
|
|
|
return Math.max(rect.width, rect.height) * (pct / 100);
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
2025-12-29 00:21:03 +01:00
|
|
|
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',
|
2025-12-29 00:21:03 +01:00
|
|
|
});
|
2025-12-29 02:34:46 +01:00
|
|
|
|
|
|
|
|
console.log('UI host created:', this.host);
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createShadow() {
|
2025-12-29 14:28:00 +01:00
|
|
|
this.shadow = this.host.attachShadow({ mode: 'open' });
|
2025-12-29 00:21:03 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 00:21:03 +01:00
|
|
|
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 00:21:03 +01:00
|
|
|
|
2025-12-29 14:28:00 +01:00
|
|
|
const trigger = document.createElement('div');
|
|
|
|
|
trigger.classList = 'uw-menu-trigger uw-trigger';
|
|
|
|
|
trigger.textContent = 'Ultrawidify';
|
2025-12-29 00:21:03 +01:00
|
|
|
|
|
|
|
|
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 00:21:03 +01:00
|
|
|
|
2025-12-29 11:32:49 +01:00
|
|
|
this.root.classList.add(...this.menuPositionClasses);
|
|
|
|
|
|
|
|
|
|
trigger.appendChild(submenu);
|
|
|
|
|
this.root.append(trigger);
|
2025-12-29 00:21:03 +01:00
|
|
|
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();
|
|
|
|
|
});
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2025-12-29 00:21:03 +01:00
|
|
|
|
|
|
|
|
for (const item of items) {
|
2025-12-29 14:28:00 +01:00
|
|
|
const el = document.createElement('div');
|
2025-12-29 11:32:49 +01:00
|
|
|
el.className = `uw-menu-item uw-trigger`;
|
2025-12-29 00:21:03 +01:00
|
|
|
|
|
|
|
|
if (item.customHTML) {
|
|
|
|
|
el.appendChild(item.customHTML);
|
|
|
|
|
} else {
|
|
|
|
|
el.textContent = item.label;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item.action) {
|
2025-12-29 14:28:00 +01:00
|
|
|
el.addEventListener('click', e => {
|
2025-12-29 00:21:03 +01:00
|
|
|
e.stopPropagation();
|
|
|
|
|
item.action?.();
|
2025-12-29 14:28:00 +01:00
|
|
|
// this.hide(); // maybe dont
|
2025-12-29 00:21:03 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bindGlobalMouse(anchorEl: HTMLElement) {
|
|
|
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
|
|
|
const cx = rect.left + rect.width / 2;
|
|
|
|
|
const cy = rect.top + rect.height / 2;
|
|
|
|
|
|
2025-12-29 14:28:00 +01:00
|
|
|
const activationRadius = this.getActivationRadius(anchorEl);
|
|
|
|
|
|
|
|
|
|
this.onDocumentMouseMove = (e: MouseEvent) => {
|
|
|
|
|
this.lastMouseMove = performance.now();
|
|
|
|
|
|
|
|
|
|
if (activationRadius != null) {
|
|
|
|
|
const d = Math.hypot(e.clientX - cx, e.clientY - cy);
|
|
|
|
|
this.isWithinActivation = d <= activationRadius;
|
|
|
|
|
} else {
|
|
|
|
|
this.isWithinActivation =
|
|
|
|
|
e.clientX >= rect.left &&
|
|
|
|
|
e.clientX <= rect.right &&
|
|
|
|
|
e.clientY >= rect.top &&
|
|
|
|
|
e.clientY <= rect.bottom;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-12-29 00:21:03 +01:00
|
|
|
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();
|
|
|
|
|
}
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private show() {
|
2025-12-29 14:28:00 +01:00
|
|
|
this.lastMouseMove = performance.now();
|
|
|
|
|
|
2025-12-29 00:21:03 +01:00
|
|
|
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');
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
2025-12-29 00:21:03 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|