Add a right-click menu to the stream, but only when not dragging (#4745)

* Refactor ContextMenu to be able to take a guard and other event types

* refactor: break out ViewControlMenu into its own component

* Add ViewControlMenu to Stream, but only on right-click non-drag mouseup

* Fix lints

* Don't use `useCallback` for contextmenu guard

* Update context menu position on subsequent right-clicks
This commit is contained in:
Frank Noirot
2024-12-11 12:57:38 -05:00
committed by GitHub
parent 00e97257ae
commit 058fccb5e1
6 changed files with 156 additions and 79 deletions

View File

@ -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()
})
})
}) })

View File

@ -1,13 +1,23 @@
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { ActionIcon, ActionIconProps } from './ActionIcon' 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 { useHotkeys } from 'react-hotkeys-hook'
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
interface ContextMenuProps export interface ContextMenuProps
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> { extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
items?: React.ReactElement[] items?: React.ReactElement[]
menuTargetElement?: RefObject<HTMLElement> menuTargetElement?: RefObject<HTMLElement>
guard?: (e: globalThis.MouseEvent) => boolean
event?: 'contextmenu' | 'mouseup'
} }
const DefaultContextMenuItems = [ const DefaultContextMenuItems = [
@ -20,6 +30,8 @@ export function ContextMenu({
items = DefaultContextMenuItems, items = DefaultContextMenuItems,
menuTargetElement, menuTargetElement,
className, className,
guard,
event = 'contextmenu',
...props ...props
}: ContextMenuProps) { }: ContextMenuProps) {
const dialogRef = useRef<HTMLDivElement>(null) const dialogRef = useRef<HTMLDivElement>(null)
@ -32,6 +44,15 @@ export function ContextMenu({
useHotkeys('esc', () => setOpen(false), { useHotkeys('esc', () => setOpen(false), {
enabled: open, 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(() => { const dialogPositionStyle = useMemo(() => {
if (!dialogRef.current) if (!dialogRef.current)
@ -78,21 +99,9 @@ export function ContextMenu({
// Add context menu listener to target once mounted // Add context menu listener to target once mounted
useEffect(() => { useEffect(() => {
const handleContextMenu = (e: MouseEvent) => { menuTargetElement?.current?.addEventListener(event, handleContextMenu)
console.log('context menu', e)
e.preventDefault()
setPosition({ x: e.x, y: e.y })
setOpen(true)
}
menuTargetElement?.current?.addEventListener(
'contextmenu',
handleContextMenu
)
return () => { return () => {
menuTargetElement?.current?.removeEventListener( menuTargetElement?.current?.removeEventListener(event, handleContextMenu)
'contextmenu',
handleContextMenu
)
} }
}, [menuTargetElement?.current]) }, [menuTargetElement?.current])
@ -100,7 +109,10 @@ export function ContextMenu({
<Dialog open={open} onClose={() => setOpen(false)}> <Dialog open={open} onClose={() => setOpen(false)}>
<div <div
className="fixed inset-0 z-50 w-screen h-screen" className="fixed inset-0 z-50 w-screen h-screen"
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => {
e.preventDefault()
setPosition({ x: e.clientX, y: e.clientY })
}}
> >
<Dialog.Backdrop className="fixed z-10 inset-0" /> <Dialog.Backdrop className="fixed z-10 inset-0" />
<Dialog.Panel <Dialog.Panel

View File

@ -1,6 +1,6 @@
import { SceneInfra } from 'clientSideScene/sceneInfra' import { SceneInfra } from 'clientSideScene/sceneInfra'
import { sceneInfra } from 'lib/singletons' import { sceneInfra } from 'lib/singletons'
import { MutableRefObject, useEffect, useMemo, useRef } from 'react' import { MutableRefObject, useEffect, useRef } from 'react'
import { import {
WebGLRenderer, WebGLRenderer,
Scene, Scene,
@ -19,16 +19,14 @@ import {
Intersection, Intersection,
Object3D, Object3D,
} from 'three' } from 'three'
import {
ContextMenu,
ContextMenuDivider,
ContextMenuItem,
ContextMenuItemRefresh,
} from './ContextMenu'
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { useModelingContext } from 'hooks/useModelingContext' import {
useViewControlMenuItems,
ViewControlContextMenu,
} from './ViewControlMenu'
import { AxisNames } from 'lib/constants'
const CANVAS_SIZE = 80 const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5 const FRUSTUM_SIZE = 0.5
@ -40,64 +38,14 @@ enum AxisColors {
Z = '#6689ef', Z = '#6689ef',
Gray = '#c6c7c2', Gray = '#c6c7c2',
} }
enum AxisNames {
X = 'x',
Y = 'y',
Z = 'z',
NEG_X = '-x',
NEG_Y = '-y',
NEG_Z = '-z',
}
const axisNamesSemantic: Record<AxisNames, string> = {
[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() { export default function Gizmo() {
const menuItems = useViewControlMenuItems()
const wrapperRef = useRef<HTMLDivElement | null>(null) const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null) const canvasRef = useRef<HTMLCanvasElement | null>(null)
const raycasterIntersect = useRef<Intersection<Object3D> | null>(null) const raycasterIntersect = useRef<Intersection<Object3D> | null>(null)
const cameraPassiveUpdateTimer = useRef(0) const cameraPassiveUpdateTimer = useRef(0)
const raycasterPassiveUpdateTimer = useRef(0) const raycasterPassiveUpdateTimer = useRef(0)
const { send: modelingSend } = useModelingContext()
const menuItems = useMemo(
() => [
...Object.entries(axisNamesSemantic).map(([axisName, axisSemantic]) => (
<ContextMenuItem
key={axisName}
onClick={() => {
sceneInfra.camControls
.updateCameraToAxis(axisName as AxisNames)
.catch(reportRejection)
}}
>
{axisSemantic} view
</ContextMenuItem>
)),
<ContextMenuDivider />,
<ContextMenuItem
onClick={() => {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
}}
>
Reset view
</ContextMenuItem>,
<ContextMenuItem
onClick={() => {
modelingSend({ type: 'Center camera on selection' })
}}
>
Center view on selection
</ContextMenuItem>,
<ContextMenuDivider />,
<ContextMenuItemRefresh />,
],
[axisNamesSemantic]
)
useEffect(() => { useEffect(() => {
if (!canvasRef.current) return 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" 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"
> >
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
<ContextMenu menuTargetElement={wrapperRef} items={menuItems} /> <ViewControlContextMenu menuTargetElement={wrapperRef} />
</div> </div>
<GizmoDropdown items={menuItems} /> <GizmoDropdown items={menuItems} />
</div> </div>

View File

@ -20,6 +20,7 @@ import { IndexLoaderData } from 'lib/types'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { err, reportRejection } from 'lib/trap' import { err, reportRejection } from 'lib/trap'
import { getArtifactOfTypes } from 'lang/std/artifactGraph' import { getArtifactOfTypes } from 'lang/std/artifactGraph'
import { ViewControlContextMenu } from './ViewControlMenu'
enum StreamState { enum StreamState {
Playing = 'playing', Playing = 'playing',
@ -30,6 +31,7 @@ enum StreamState {
export const Stream = () => { export const Stream = () => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const videoWrapperRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const { state, send } = useModelingContext() const { state, send } = useModelingContext()
@ -258,7 +260,7 @@ export const Stream = () => {
setIsLoading(false) setIsLoading(false)
}, [mediaStream]) }, [mediaStream])
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => { const handleClick: MouseEventHandler<HTMLDivElement> = (e) => {
// If we've got no stream or connection, don't do anything // If we've got no stream or connection, don't do anything
if (!isNetworkOkay) return if (!isNetworkOkay) return
if (!videoRef.current) return if (!videoRef.current) return
@ -320,10 +322,11 @@ export const Stream = () => {
return ( return (
<div <div
ref={videoWrapperRef}
className="absolute inset-0 z-0" className="absolute inset-0 z-0"
id="stream" id="stream"
data-testid="stream" data-testid="stream"
onClick={handleMouseUp} onClick={handleClick}
onDoubleClick={enterSketchModeIfSelectingSketch} onDoubleClick={enterSketchModeIfSelectingSketch}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
onContextMenuCapture={(e) => e.preventDefault()} onContextMenuCapture={(e) => e.preventDefault()}
@ -384,6 +387,14 @@ export const Stream = () => {
</Loading> </Loading>
</div> </div>
)} )}
<ViewControlContextMenu
event="mouseup"
guard={(e) =>
sceneInfra.camControls.wasDragging === false &&
btnName(e).right === true
}
menuTargetElement={videoWrapperRef}
/>
</div> </div>
) )
} }

View File

@ -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]) => (
<ContextMenuItem
key={axisName}
onClick={() => {
sceneInfra.camControls
.updateCameraToAxis(axisName as AxisNames)
.catch(reportRejection)
}}
>
{axisSemantic} view
</ContextMenuItem>
)),
<ContextMenuDivider />,
<ContextMenuItem
onClick={() => {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
}}
>
Reset view
</ContextMenuItem>,
<ContextMenuItem
onClick={() => {
modelingSend({ type: 'Center camera on selection' })
}}
>
Center view on selection
</ContextMenuItem>,
<ContextMenuDivider />,
<ContextMenuItemRefresh />,
],
[VIEW_NAMES_SEMANTIC]
)
return menuItems
}
export function ViewControlContextMenu({
menuTargetElement: wrapperRef,
...props
}: ContextMenuProps) {
const menuItems = useViewControlMenuItems()
return (
<ContextMenu
data-testid="view-controls-menu"
menuTargetElement={wrapperRef}
items={menuItems}
{...props}
/>
)
}

View File

@ -118,3 +118,21 @@ export const KCL_AXIS_Y = 'Y'
export const KCL_AXIS_NEG_X = '-X' export const KCL_AXIS_NEG_X = '-X'
export const KCL_AXIS_NEG_Y = '-Y' export const KCL_AXIS_NEG_Y = '-Y'
export const KCL_DEFAULT_AXIS = 'X' 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