131 lines
3.2 KiB
TypeScript
131 lines
3.2 KiB
TypeScript
import { createEffect, createSignal, onCleanup } from "solid-js";
|
|
import type { ProjectMenuTarget } from "../../app-shell/data/shell.data";
|
|
|
|
type ProjectContextMenuState = {
|
|
target: ProjectMenuTarget;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
const readRootPixelToken = (name: string, fallback: number): number => {
|
|
if (typeof window === "undefined") {
|
|
return fallback;
|
|
}
|
|
|
|
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
const parsed = Number.parseFloat(value);
|
|
|
|
if (!Number.isFinite(parsed)) {
|
|
return fallback;
|
|
}
|
|
|
|
if (value.endsWith("px")) {
|
|
return parsed;
|
|
}
|
|
|
|
return parsed * 16;
|
|
};
|
|
|
|
const clampMenuPosition = (value: number, min: number, max: number): number => {
|
|
if (max <= min) {
|
|
return min;
|
|
}
|
|
|
|
return Math.min(Math.max(value, min), max);
|
|
};
|
|
|
|
export const createProjectContextMenuController = () => {
|
|
const [menuState, setMenuState] = createSignal<ProjectContextMenuState | null>(null);
|
|
let menuRef: HTMLDivElement | undefined;
|
|
|
|
const closeMenu = (): void => {
|
|
setMenuState(null);
|
|
};
|
|
|
|
const repositionMenu = (): void => {
|
|
if (typeof window === "undefined" || !menuRef) {
|
|
return;
|
|
}
|
|
|
|
const current = menuState();
|
|
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
const viewportPadding = readRootPixelToken("--space-4", 16);
|
|
const rect = menuRef.getBoundingClientRect();
|
|
const nextX = clampMenuPosition(current.x, viewportPadding, window.innerWidth - rect.width - viewportPadding);
|
|
const nextY = clampMenuPosition(current.y, viewportPadding, window.innerHeight - rect.height - viewportPadding);
|
|
|
|
if (nextX === current.x && nextY === current.y) {
|
|
return;
|
|
}
|
|
|
|
setMenuState({ ...current, x: nextX, y: nextY });
|
|
};
|
|
|
|
const openMenu = (event: MouseEvent, target: ProjectMenuTarget): void => {
|
|
event.preventDefault();
|
|
setMenuState({ target, x: event.clientX, y: event.clientY });
|
|
};
|
|
|
|
const openMenuFromElement = (element: HTMLElement, target: ProjectMenuTarget): void => {
|
|
const rect = element.getBoundingClientRect();
|
|
setMenuState({
|
|
target,
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2,
|
|
});
|
|
};
|
|
|
|
createEffect(() => {
|
|
if (!menuState() || typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const frame = window.requestAnimationFrame(() => {
|
|
repositionMenu();
|
|
});
|
|
|
|
const handlePointerDown = (event: PointerEvent): void => {
|
|
if (!menuRef?.contains(event.target as Node)) {
|
|
closeMenu();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
if (event.key === "Escape") {
|
|
closeMenu();
|
|
}
|
|
};
|
|
|
|
const handleViewportChange = (): void => {
|
|
closeMenu();
|
|
};
|
|
|
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
window.addEventListener("resize", handleViewportChange);
|
|
window.addEventListener("scroll", handleViewportChange, true);
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
onCleanup(() => {
|
|
window.cancelAnimationFrame(frame);
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
window.removeEventListener("resize", handleViewportChange);
|
|
window.removeEventListener("scroll", handleViewportChange, true);
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
});
|
|
});
|
|
|
|
return {
|
|
menuState,
|
|
openMenu,
|
|
openMenuFromElement,
|
|
closeMenu,
|
|
setMenuRef: (element: HTMLDivElement): void => {
|
|
menuRef = element;
|
|
},
|
|
};
|
|
};
|