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:
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
66
src/components/ViewControlMenu.tsx
Normal file
66
src/components/ViewControlMenu.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -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
|
||||||
|
Reference in New Issue
Block a user