import { preventDefault, useContainer, useEditor, useEditorComponents, useValue, } from '@tldraw/editor' import { ContextMenu as _ContextMenu } from 'radix-ui' import { ReactNode, memo, useCallback, useEffect, useRef } from 'react' import { useMenuIsOpen } from '../../hooks/useMenuIsOpen' import { useDirection, useTranslation } from '../../hooks/useTranslation/useTranslation' import { TldrawUiMenuContextProvider } from '../primitives/menus/TldrawUiMenuContext' import { DefaultContextMenuContent } from './DefaultContextMenuContent' /** @public */ export interface TLUiContextMenuProps { children?: ReactNode disabled?: boolean } /** @public @react */ export const DefaultContextMenu = memo(function DefaultContextMenu({ children, disabled = false, }: TLUiContextMenuProps) { const editor = useEditor() const msg = useTranslation() const { Canvas } = useEditorComponents() // The context menu opens from a right-click in any tool, and from a touch // long-press only in the select tool (where it acts on the selection). A // right-click (fine pointer) is routed through the select tool by the editor; a // long-press in any other tool belongs to that tool's gesture (creating, erasing, // drawing, panning), so it opens nothing. // // The right-click case keys off the pointer type, not the tool, on purpose: a // right-click switches to select synchronously within the same gesture, so gating // it on the tool would race the React render and wrongly suppress the menu. The // select-tool long-press case is safe to gate on the tool — a long-press never // switches tools, so there is nothing to race. const menuCanOpen = useValue( 'context menu can open', () => !editor.getInstanceState().isCoarsePointer || editor.isIn('select'), [editor] ) // When hitting `Escape` while the context menu is open, we want to prevent // the default behavior of losing focus on the shape. Otherwise, // it's pretty annoying from an accessibility perspective. const preventEscapeFromLosingShapeFocus = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation() editor.getContainer().focus() } }, [editor] ) useEffect(() => { const body = editor.getContainerDocument().body return () => { body.removeEventListener('keydown', preventEscapeFromLosingShapeFocus, { capture: true, }) } }, [editor, preventEscapeFromLosingShapeFocus]) // On touch devices, the same touch that triggers Radix's long-press open is still // down when the menu mounts. The release fires events the dismissable layer treats // as an outside interaction and closes the menu. We swallow dismissals during a // short grace window after open so the menu stays put until the user actually // interacts again. const suppressDismissUntilRef = useRef(0) const cb = useCallback( (isOpen: boolean) => { const body = editor.getContainerDocument().body if (!isOpen) { const onlySelectedShape = editor.getOnlySelectedShape() if (onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape)) { editor.setSelectedShapes([]) } editor.timers.requestAnimationFrame(() => { body.removeEventListener('keydown', preventEscapeFromLosingShapeFocus, { capture: true, }) }) } else { body.addEventListener('keydown', preventEscapeFromLosingShapeFocus, { capture: true, }) if (editor.getInstanceState().isCoarsePointer) { suppressDismissUntilRef.current = Date.now() + 500 // Weird route: selecting locked shapes on long press const selectedShapes = editor.getSelectedShapes() const currentPagePoint = editor.inputs.getCurrentPagePoint() // get all of the shapes under the current pointer const shapesAtPoint = editor.getShapesAtPoint(currentPagePoint) if ( // if there are no selected shapes !editor.getSelectedShapes().length || // OR if none of the shapes at the point include the selected shape !shapesAtPoint.some((s) => selectedShapes.includes(s)) ) { // then are there any locked shapes under the current pointer? const lockedShapes = shapesAtPoint.filter((s) => editor.isShapeOrAncestorLocked(s)) if (lockedShapes.length) { // nice, let's select them editor.select(...lockedShapes.map((s) => s.id)) } } } } }, [editor, preventEscapeFromLosingShapeFocus] ) const container = useContainer() const dir = useDirection() const [isOpen, handleOpenChange] = useMenuIsOpen('context menu', cb) // Get the context menu content, either the default component or the user's // override. If there's no menu content, then the user has set it to null, // so skip rendering the menu. const content = children ?? return ( <_ContextMenu.Root dir={dir} onOpenChange={handleOpenChange} modal={false}> <_ContextMenu.Trigger // When suppressed, disabling the trigger stops Radix from opening the // menu, but it also stops Radix from preventing the native contextmenu — // so prevent the browser's own menu ourselves in that case. onContextMenu={menuCanOpen ? undefined : preventDefault} dir="ltr" disabled={disabled || !menuCanOpen} > {Canvas ? : null} {isOpen && ( <_ContextMenu.Portal container={container}> <_ContextMenu.Content className="tlui-menu tlui-scrollable" data-testid="context-menu" aria-label={msg('context-menu.title')} alignOffset={-4} collisionPadding={4} onContextMenu={preventDefault} onPointerDownOutside={(e) => { if (Date.now() < suppressDismissUntilRef.current) e.preventDefault() }} onInteractOutside={(e) => { if (Date.now() < suppressDismissUntilRef.current) e.preventDefault() }} onFocusOutside={(e) => { if (Date.now() < suppressDismissUntilRef.current) e.preventDefault() }} > {content} )} ) })