fluxer/fluxer_app/src/actions/ContextMenuActionCreators.tsx
2026-01-01 21:05:54 +00:00

150 lines
4.7 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type React from 'react';
import type {ContextMenu, ContextMenuConfig, ContextMenuTargetElement} from '~/stores/ContextMenuStore';
import ContextMenuStore from '~/stores/ContextMenuStore';
const nativeContextMenuTarget: ContextMenuTargetElement = {
tagName: 'ReactNativeContextMenu',
isConnected: true,
focus: (): void => undefined,
addEventListener: (..._args: Parameters<HTMLElement['addEventListener']>) => undefined,
removeEventListener: (..._args: Parameters<HTMLElement['removeEventListener']>) => undefined,
};
const makeId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random()}`;
const getViewportCenterForElement = (el: Element) => {
const rect = el.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset || 0;
const scrollY = window.scrollY || window.pageYOffset || 0;
return {x: rect.left + rect.width / 2 + scrollX, y: rect.top + rect.height / 2 + scrollY};
};
const toHTMLElement = (value: unknown): HTMLElement | null => {
if (!value) return null;
if (value instanceof HTMLElement) return value;
if (value instanceof Element) {
return (value.closest('button,[role="button"],a,[data-contextmenu-anchor="true"]') as HTMLElement | null) ?? null;
}
return null;
};
export const close = (): void => {
ContextMenuStore.close();
};
type RenderFn = (props: {onClose: () => void}) => React.ReactNode;
export const openAtPoint = (
point: {x: number; y: number},
render: RenderFn,
config?: ContextMenuConfig,
target: ContextMenuTargetElement = nativeContextMenuTarget,
): void => {
const contextMenu: ContextMenu = {
id: makeId('context-menu'),
target: {x: point.x, y: point.y, target},
render,
config: {noBlurEvent: true, ...config},
};
ContextMenuStore.open(contextMenu);
};
export const openForElement = (
element: HTMLElement,
render: RenderFn,
options?: {point?: {x: number; y: number}; config?: ContextMenuConfig},
): void => {
const point = options?.point ?? getViewportCenterForElement(element);
openAtPoint(point, render, options?.config, element);
};
export const openFromEvent = (
event: React.MouseEvent | MouseEvent,
render: RenderFn,
config?: ContextMenuConfig,
): void => {
event.preventDefault?.();
event.stopPropagation?.();
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
const target = 'target' in event ? toHTMLElement(event.target) : null;
const anchor = currentTarget ?? target;
const hasPointerCoords = !(event.pageX === 0 && event.pageY === 0 && nativeEvent.detail === 0);
const point = hasPointerCoords
? {x: event.pageX + 2, y: event.pageY + 2}
: anchor
? (() => {
const c = getViewportCenterForElement(anchor);
return {x: c.x + 2, y: c.y + 2};
})()
: {x: 0, y: 0};
openAtPoint(point, render, config, anchor ?? nativeContextMenuTarget);
};
export const openFromElementBottomRight = (
event: React.MouseEvent | MouseEvent,
render: RenderFn,
config?: ContextMenuConfig,
): void => {
event.preventDefault?.();
event.stopPropagation?.();
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
const target = 'target' in event ? toHTMLElement(event.target) : null;
const anchor = currentTarget ?? target;
if (!anchor) {
openFromEvent(event, render, config);
return;
}
const rect = anchor.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset || 0;
const scrollY = window.scrollY || window.pageYOffset || 0;
const point = {x: rect.right + scrollX, y: rect.bottom + scrollY + 4};
openAtPoint(point, render, {align: 'top-right', ...config}, anchor);
};
export const openNativeContextMenu = (render: RenderFn, config?: ContextMenu['config']): void => {
const contextMenu: ContextMenu = {
id: makeId('native-context-menu'),
target: {
x: 0,
y: 0,
target: nativeContextMenuTarget,
},
render,
config: {
returnFocus: false,
...config,
},
};
ContextMenuStore.open(contextMenu);
};