'use client';
import { createPortal } from 'react-dom';
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type ReactNode,
} from 'react';
import type { PPTElement } from '@openmaic/dsl';
import type { Selection } from '../../react/types';
import type { CanvasContextMenuLabels, CanvasContextMenuOptions } from '../types';
interface CanvasContextMenuProps extends CanvasContextMenuOptions {
readonly children: ReactNode;
readonly elements: readonly PPTElement[];
readonly selection: Selection;
readonly onSelectionChange: (selection: Selection) => void;
}
type MenuState =
| { readonly kind: 'canvas' }
| { readonly kind: 'locked'; readonly targetId: string }
| {
readonly kind: 'element';
readonly targetId: string;
readonly groupAction?: 'group' | 'ungroup';
};
const DEFAULT_LABELS: CanvasContextMenuLabels = {
horizontalAlignment: 'Horizontal alignment',
verticalAlignment: 'Vertical alignment',
selectAll: 'Select all',
copy: 'Copy',
cut: 'Cut',
paste: 'Paste',
unlock: 'Unlock',
lock: 'Lock',
delete: 'Delete',
group: 'Group',
ungroup: 'Ungroup',
bringToFront: 'Bring to front',
bringForward: 'Bring forward',
sendToBack: 'Send to back',
sendBackward: 'Send backward',
alignLeft: 'Align left',
alignCenter: 'Align center',
alignRight: 'Align right',
alignTop: 'Align top',
alignMiddle: 'Align middle',
alignBottom: 'Align bottom',
};
function groupMembers(elements: readonly PPTElement[], target: PPTElement): PPTElement[] {
return target.groupId
? elements.filter((element) => element.groupId === target.groupId)
: [target];
}
function selectionForTarget(
elements: readonly PPTElement[],
selection: Selection,
targetId: string | null,
): Selection | null {
if (!targetId) return null;
const target = elements.find((element) => element.id === targetId);
if (!target || target.lock) return null;
const groupIds = groupMembers(elements, target).map((element) => element.id);
if (
selection.elementIds.includes(target.id) &&
groupIds.every((id) => selection.elementIds.includes(id))
) {
return null;
}
return { elementIds: groupIds, primaryId: target.id };
}
function getMenuState(
elements: readonly PPTElement[],
selection: Selection,
targetId: string | null,
): MenuState {
if (!targetId) return { kind: 'canvas' };
const target = elements.find((element) => element.id === targetId);
if (!target) return { kind: 'canvas' };
if (target.lock) return { kind: 'locked', targetId };
const nextSelection = selectionForTarget(elements, selection, targetId) ?? selection;
const selected = elements.filter((element) => nextSelection.elementIds.includes(element.id));
if (selected.length < 2) return { kind: 'element', targetId };
const groupId = selected[0].groupId;
const sameGroup = Boolean(groupId) && selected.every((element) => element.groupId === groupId);
return { kind: 'element', targetId, groupAction: sameGroup ? 'ungroup' : 'group' };
}
function shortcut(label: string, value: string) {
return (
<>
{label}
{value}
>
);
}
function MenuItem({
children,
destructive = false,
onClick,
}: {
readonly children: ReactNode;
readonly destructive?: boolean;
readonly onClick: () => void;
}) {
return (
);
}
function MenuSubmenu({
label,
children,
}: {
readonly label: string;
readonly children: ReactNode;
}) {
return (
);
}
export function CanvasContextMenu({
children,
elements,
selection,
onSelectionChange,
labels: labelOverrides,
onSelectAll,
onCopy,
onCut,
onPaste,
onUnlock,
onLock,
onDelete,
onToggleGroup,
onReorder,
onAlign,
}: CanvasContextMenuProps) {
const rootRef = useRef(null);
const menuRef = useRef(null);
const [targetId, setTargetId] = useState(null);
const [point, setPoint] = useState<{ left: number; top: number } | null>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
const labels = useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);
const menu = useMemo(
() => getMenuState(elements, selection, targetId),
[elements, selection, targetId],
);
const isOpen = point !== null;
const close = () => {
setPoint(null);
setPosition(null);
};
useLayoutEffect(() => {
if (!point || !menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
setPosition({
left: Math.max(8, Math.min(point.left, window.innerWidth - rect.width - 8)),
top: Math.max(8, Math.min(point.top, window.innerHeight - rect.height - 8)),
});
}, [menu, point]);
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current?.contains(event.target as Node)) return;
close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
const target = event.target as Element;
const host = target.closest?.('[data-element-id], [data-context-element-id]');
const nextTargetId =
host?.getAttribute('data-element-id') ||
host?.getAttribute('data-context-element-id') ||
null;
const nextSelection = selectionForTarget(elements, selection, nextTargetId);
if (nextSelection) onSelectionChange(nextSelection);
setTargetId(nextTargetId);
setPosition(null);
setPoint({ left: event.clientX, top: event.clientY });
};
const invoke = (callback: () => void | Promise) => {
void Promise.resolve(callback()).finally(close);
};
return (
{children}
{isOpen && typeof document !== 'undefined'
? createPortal(
{menu.kind === 'canvas' ? (
<>
>
) : null}
{menu.kind === 'locked' ? (
) : null}
{menu.kind === 'element' ? (
<>
{menu.groupAction ? (
) : null}
>
) : null}
,
document.body,
)
: null}
);
}