diff --git a/e2e/playwright/testing-camera-movement.spec.ts b/e2e/playwright/testing-camera-movement.spec.ts index c4bbd8ebf..b6bddc11c 100644 --- a/e2e/playwright/testing-camera-movement.spec.ts +++ b/e2e/playwright/testing-camera-movement.spec.ts @@ -479,4 +479,26 @@ test.describe('Testing Camera Movement', () => { }) } }) + + test('Right-click opens context menu when not dragged', async ({ page }) => { + const u = await getUtils(page) + await u.waitForAuthSkipAppStart() + + await test.step(`The menu should not show if we drag the mouse`, async () => { + await page.mouse.move(900, 200) + await page.mouse.down({ button: 'right' }) + await page.mouse.move(900, 300) + await page.mouse.up({ button: 'right' }) + + await expect(page.getByTestId('view-controls-menu')).not.toBeVisible() + }) + + await test.step(`The menu should show if we don't drag the mouse`, async () => { + await page.mouse.move(900, 200) + await page.mouse.down({ button: 'right' }) + await page.mouse.up({ button: 'right' }) + + await expect(page.getByTestId('view-controls-menu')).toBeVisible() + }) + }) }) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 05722cca4..bb17a559e 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,13 +1,23 @@ import toast from 'react-hot-toast' import { ActionIcon, ActionIconProps } from './ActionIcon' -import { RefObject, useEffect, useMemo, useRef, useState } from 'react' +import { + MouseEvent, + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { Dialog } from '@headlessui/react' -interface ContextMenuProps +export interface ContextMenuProps extends Omit, 'children'> { items?: React.ReactElement[] menuTargetElement?: RefObject + guard?: (e: globalThis.MouseEvent) => boolean + event?: 'contextmenu' | 'mouseup' } const DefaultContextMenuItems = [ @@ -20,6 +30,8 @@ export function ContextMenu({ items = DefaultContextMenuItems, menuTargetElement, className, + guard, + event = 'contextmenu', ...props }: ContextMenuProps) { const dialogRef = useRef(null) @@ -32,6 +44,15 @@ export function ContextMenu({ useHotkeys('esc', () => setOpen(false), { enabled: open, }) + const handleContextMenu = useCallback( + (e: globalThis.MouseEvent) => { + if (guard && !guard(e)) return + e.preventDefault() + setPosition({ x: e.clientX, y: e.clientY }) + setOpen(true) + }, + [guard, setPosition, setOpen] + ) const dialogPositionStyle = useMemo(() => { if (!dialogRef.current) @@ -78,21 +99,9 @@ export function ContextMenu({ // Add context menu listener to target once mounted useEffect(() => { - const handleContextMenu = (e: MouseEvent) => { - console.log('context menu', e) - e.preventDefault() - setPosition({ x: e.x, y: e.y }) - setOpen(true) - } - menuTargetElement?.current?.addEventListener( - 'contextmenu', - handleContextMenu - ) + menuTargetElement?.current?.addEventListener(event, handleContextMenu) return () => { - menuTargetElement?.current?.removeEventListener( - 'contextmenu', - handleContextMenu - ) + menuTargetElement?.current?.removeEventListener(event, handleContextMenu) } }, [menuTargetElement?.current]) @@ -100,7 +109,10 @@ export function ContextMenu({ setOpen(false)}>
e.preventDefault()} + onContextMenu={(e) => { + e.preventDefault() + setPosition({ x: e.clientX, y: e.clientY }) + }} > = { - [AxisNames.X]: 'Right', - [AxisNames.Y]: 'Back', - [AxisNames.Z]: 'Top', - [AxisNames.NEG_X]: 'Left', - [AxisNames.NEG_Y]: 'Front', - [AxisNames.NEG_Z]: 'Bottom', -} export default function Gizmo() { + const menuItems = useViewControlMenuItems() const wrapperRef = useRef(null) const canvasRef = useRef(null) const raycasterIntersect = useRef | null>(null) const cameraPassiveUpdateTimer = useRef(0) const raycasterPassiveUpdateTimer = useRef(0) - const { send: modelingSend } = useModelingContext() - const menuItems = useMemo( - () => [ - ...Object.entries(axisNamesSemantic).map(([axisName, axisSemantic]) => ( - { - sceneInfra.camControls - .updateCameraToAxis(axisName as AxisNames) - .catch(reportRejection) - }} - > - {axisSemantic} view - - )), - , - { - sceneInfra.camControls.resetCameraPosition().catch(reportRejection) - }} - > - Reset view - , - { - modelingSend({ type: 'Center camera on selection' }) - }} - > - Center view on selection - , - , - , - ], - [axisNamesSemantic] - ) useEffect(() => { if (!canvasRef.current) return @@ -161,7 +109,7 @@ export default function Gizmo() { className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-auto bg-chalkboard-10/70 dark:bg-chalkboard-100/80 backdrop-blur-sm" > - +
diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index c89a407bc..ad3e51f4a 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -20,6 +20,7 @@ import { IndexLoaderData } from 'lib/types' import { useCommandsContext } from 'hooks/useCommandsContext' import { err, reportRejection } from 'lib/trap' import { getArtifactOfTypes } from 'lang/std/artifactGraph' +import { ViewControlContextMenu } from './ViewControlMenu' enum StreamState { Playing = 'playing', @@ -30,6 +31,7 @@ enum StreamState { export const Stream = () => { const [isLoading, setIsLoading] = useState(true) + const videoWrapperRef = useRef(null) const videoRef = useRef(null) const { settings } = useSettingsAuthContext() const { state, send } = useModelingContext() @@ -258,7 +260,7 @@ export const Stream = () => { setIsLoading(false) }, [mediaStream]) - const handleMouseUp: MouseEventHandler = (e) => { + const handleClick: MouseEventHandler = (e) => { // If we've got no stream or connection, don't do anything if (!isNetworkOkay) return if (!videoRef.current) return @@ -320,10 +322,11 @@ export const Stream = () => { return (
e.preventDefault()} onContextMenuCapture={(e) => e.preventDefault()} @@ -384,6 +387,14 @@ export const Stream = () => {
)} + + sceneInfra.camControls.wasDragging === false && + btnName(e).right === true + } + menuTargetElement={videoWrapperRef} + /> ) } diff --git a/src/components/ViewControlMenu.tsx b/src/components/ViewControlMenu.tsx new file mode 100644 index 000000000..68c8b2578 --- /dev/null +++ b/src/components/ViewControlMenu.tsx @@ -0,0 +1,66 @@ +import { reportRejection } from 'lib/trap' +import { + ContextMenu, + ContextMenuDivider, + ContextMenuItem, + ContextMenuItemRefresh, + ContextMenuProps, +} from './ContextMenu' +import { AxisNames, VIEW_NAMES_SEMANTIC } from 'lib/constants' +import { useModelingContext } from 'hooks/useModelingContext' +import { useMemo } from 'react' +import { sceneInfra } from 'lib/singletons' + +export function useViewControlMenuItems() { + const { send: modelingSend } = useModelingContext() + const menuItems = useMemo( + () => [ + ...Object.entries(VIEW_NAMES_SEMANTIC).map(([axisName, axisSemantic]) => ( + { + sceneInfra.camControls + .updateCameraToAxis(axisName as AxisNames) + .catch(reportRejection) + }} + > + {axisSemantic} view + + )), + , + { + sceneInfra.camControls.resetCameraPosition().catch(reportRejection) + }} + > + Reset view + , + { + modelingSend({ type: 'Center camera on selection' }) + }} + > + Center view on selection + , + , + , + ], + [VIEW_NAMES_SEMANTIC] + ) + return menuItems +} + +export function ViewControlContextMenu({ + menuTargetElement: wrapperRef, + ...props +}: ContextMenuProps) { + const menuItems = useViewControlMenuItems() + return ( + + ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 66320c02a..2326d82d0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -118,3 +118,21 @@ export const KCL_AXIS_Y = 'Y' export const KCL_AXIS_NEG_X = '-X' export const KCL_AXIS_NEG_Y = '-Y' export const KCL_DEFAULT_AXIS = 'X' + +export enum AxisNames { + X = 'x', + Y = 'y', + Z = 'z', + NEG_X = '-x', + NEG_Y = '-y', + NEG_Z = '-z', +} +/** Semantic names of views from AxisNames */ +export const VIEW_NAMES_SEMANTIC = { + [AxisNames.X]: 'Right', + [AxisNames.Y]: 'Back', + [AxisNames.Z]: 'Top', + [AxisNames.NEG_X]: 'Left', + [AxisNames.NEG_Y]: 'Front', + [AxisNames.NEG_Z]: 'Bottom', +} as const