import { preventDefault, useContainer, useEditor, useEditorComponents } 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()
// 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 onContextMenu={undefined} dir="ltr" disabled={disabled}>
{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}
)}
)
})