Add a right-click (or "context") menu to file tree and gizmo (#2628)

* Basic context menu components

* Working context menu!

* Show keyboard shortcuts in file tree context menu

* Add context menu to Gizmo

* Little polish on components

* Add a test for the gizmo, firm up other gizmo tests

* Updated Cargo lock

* Updated Cargo.lock
This commit is contained in:
Frank Noirot
2024-06-06 19:56:46 -04:00
committed by GitHub
parent 8011594e24
commit 24f4bf160f
6 changed files with 475 additions and 95 deletions

View File

@ -17,6 +17,7 @@ import {
TEST_SETTINGS_CORRUPTED,
TEST_SETTINGS_ONBOARDING_EXPORT,
TEST_SETTINGS_ONBOARDING_START,
TEST_CODE_GIZMO,
} from './storageStates'
import * as TOML from '@iarna/toml'
import { LineInputsType } from 'lang/std/sketchcombos'
@ -4844,25 +4845,25 @@ test.describe('Testing Gizmo', () => {
expectedCameraTarget: { x: 800, y: -152, z: 26 },
},
{
testDescription: '+x view',
testDescription: 'right view',
clickPosition: { x: 929, y: 417 },
expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 },
},
{
testDescription: '-x view',
testDescription: 'left view',
clickPosition: { x: 974, y: 397 },
expectedCameraPosition: { x: -4060.02, y: -152, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 },
},
{
testDescription: '+y view',
testDescription: 'back view',
clickPosition: { x: 967, y: 421 },
expectedCameraPosition: { x: 800, y: 4708.02, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 },
},
{
testDescription: '-y view',
testDescription: 'front view',
clickPosition: { x: 935, y: 393 },
expectedCameraPosition: { x: 800, y: -5012.02, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 },
@ -4874,34 +4875,11 @@ test.describe('Testing Gizmo', () => {
expectedCameraTarget,
testDescription,
} of cases) {
test(`check ${testDescription}`, async ({ page }) => {
test(`check ${testDescription}`, async ({ page, browserName }) => {
const u = await getUtils(page)
await page.addInitScript(async (KCL_DEFAULT_LENGTH) => {
localStorage.setItem(
'persistCode',
`const part001 = startSketchOn('XZ')
|> startProfileAt([20, 0], %)
|> line([7.13, 4 + 0], %)
|> angledLine({ angle: 3 + 0, length: 3.14 + 0 }, %)
|> lineTo([20.14 + 0, -0.14 + 0], %)
|> xLineTo(29 + 0, %)
|> yLine(-3.14 + 0, %, 'a')
|> xLine(1.63, %)
|> angledLineOfXLength({ angle: 3 + 0, length: 3.14 }, %)
|> angledLineOfYLength({ angle: 30, length: 3 + 0 }, %)
|> angledLineToX({ angle: 22.14 + 0, to: 12 }, %)
|> angledLineToY({ angle: 30, to: 11.14 }, %)
|> angledLineThatIntersects({
angle: 3.14,
intersectTag: 'a',
offset: 0
}, %)
|> tangentialArcTo([13.14 + 0, 13.14], %)
|> close(%)
|> extrude(5 + 7, %)
`
)
}, KCL_DEFAULT_LENGTH)
await page.addInitScript((TEST_CODE_GIZMO) => {
localStorage.setItem('persistCode', TEST_CODE_GIZMO)
}, TEST_CODE_GIZMO)
await page.setViewportSize({ width: 1000, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -4938,11 +4916,11 @@ test.describe('Testing Gizmo', () => {
})
await u.waitForCmdReceive('default_camera_get_settings')
await page.waitForTimeout(400)
await u.clearCommandLogs()
await page.mouse.move(clickPosition.x, clickPosition.y)
await page.waitForTimeout(100)
await u.clearCommandLogs()
await page.mouse.click(clickPosition.x, clickPosition.y)
await page.mouse.move(0, 0)
await u.waitForCmdReceive('default_camera_look_at')
await u.clearCommandLogs()
@ -4954,7 +4932,6 @@ test.describe('Testing Gizmo', () => {
},
})
await u.waitForCmdReceive('default_camera_get_settings')
await page.waitForTimeout(400)
await Promise.all([
// position
@ -4980,6 +4957,103 @@ test.describe('Testing Gizmo', () => {
])
})
}
test('Context menu', async ({ page }) => {
const testCase = {
testDescription: 'Right view',
expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 },
}
// Test prelude taken from the above test
const u = await getUtils(page)
await page.addInitScript((TEST_CODE_GIZMO) => {
localStorage.setItem('persistCode', TEST_CODE_GIZMO)
}, TEST_CODE_GIZMO)
await page.setViewportSize({ width: 1000, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await page.waitForTimeout(100)
// wait for execution done
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
vantage: {
x: 3000,
y: 3000,
z: 3000,
},
center: {
x: 800,
y: -152,
z: 26,
},
up: { x: 0, y: 0, z: 1 },
},
})
await page.waitForTimeout(100)
await u.clearCommandLogs()
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await u.waitForCmdReceive('default_camera_get_settings')
// Now find and select the correct
// view from the context menu
await u.clearCommandLogs()
const gizmo = page.locator('[aria-label*=gizmo]')
await gizmo.click({ button: 'right' })
const buttonToTest = page.getByRole('button', {
name: testCase.testDescription,
})
await expect(buttonToTest).toBeVisible()
await buttonToTest.click()
// Now assert we've moved to the correct view
// Taken from the above test
await u.waitForCmdReceive('default_camera_look_at')
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await u.waitForCmdReceive('default_camera_get_settings')
await page.waitForTimeout(400)
await Promise.all([
// position
expect(page.getByTestId('cam-x-position')).toHaveValue(
testCase.expectedCameraPosition.x.toString()
),
expect(page.getByTestId('cam-y-position')).toHaveValue(
testCase.expectedCameraPosition.y.toString()
),
expect(page.getByTestId('cam-z-position')).toHaveValue(
testCase.expectedCameraPosition.z.toString()
),
// target
expect(page.getByTestId('cam-x-target')).toHaveValue(
testCase.expectedCameraTarget.x.toString()
),
expect(page.getByTestId('cam-y-target')).toHaveValue(
testCase.expectedCameraTarget.y.toString()
),
expect(page.getByTestId('cam-z-target')).toHaveValue(
testCase.expectedCameraTarget.z.toString()
),
])
})
})
test('Successful export shows a success toast', async ({ page }) => {

View File

@ -50,3 +50,25 @@ export const TEST_SETTINGS_CORRUPTED = {
textWrapping: true,
},
} satisfies Partial<SaveSettingsPayload>
export const TEST_CODE_GIZMO = `const part001 = startSketchOn('XZ')
|> startProfileAt([20, 0], %)
|> line([7.13, 4 + 0], %)
|> angledLine({ angle: 3 + 0, length: 3.14 + 0 }, %)
|> lineTo([20.14 + 0, -0.14 + 0], %)
|> xLineTo(29 + 0, %)
|> yLine(-3.14 + 0, %, 'a')
|> xLine(1.63, %)
|> angledLineOfXLength({ angle: 3 + 0, length: 3.14 }, %)
|> angledLineOfYLength({ angle: 30, length: 3 + 0 }, %)
|> angledLineToX({ angle: 22.14 + 0, to: 12 }, %)
|> angledLineToY({ angle: 30, to: 11.14 }, %)
|> angledLineThatIntersects({
angle: 3.14,
intersectTag: 'a',
offset: 0
}, %)
|> tangentialArcTo([13.14 + 0, 13.14], %)
|> close(%)
|> extrude(5 + 7, %)
`

111
src-tauri/Cargo.lock generated
View File

@ -200,7 +200,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-updater",
"tokio",
"toml 0.8.13",
"toml 0.8.14",
"url",
]
@ -766,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a969e13a7589e9e3e4207e153bae624ade2b5622fb4684a4923b23ec3d57719"
dependencies = [
"serde",
"toml 0.8.13",
"toml 0.8.14",
]
[[package]]
@ -1373,7 +1373,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.8.13",
"toml 0.8.14",
"vswhom",
"winreg 0.52.0",
]
@ -2578,8 +2578,6 @@ dependencies = [
"gltf-json",
"js-sys",
"kittycad",
"kittycad-execution-plan-macros",
"kittycad-execution-plan-traits",
"lazy_static",
"mime_guess",
"parse-display",
@ -2592,7 +2590,7 @@ dependencies = [
"thiserror",
"tokio",
"tokio-tungstenite",
"toml 0.8.13",
"toml 0.8.14",
"tower-lsp",
"ts-rs",
"url",
@ -2655,28 +2653,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "kittycad-execution-plan-macros"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0611fc9b9786175da21d895ffa0f65039e19c9111e94a41b7af999e3b95f045f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "kittycad-execution-plan-traits"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "123cb47e2780ea8ef3aa67b4db237a27b388d3d3b96db457e274aa4565723151"
dependencies = [
"serde",
"thiserror",
"uuid",
]
[[package]]
name = "kuchikiki"
version = "0.8.2"
@ -3353,9 +3329,9 @@ dependencies = [
[[package]]
name = "parse-display"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06af5f9333eb47bd9ba8462d612e37a8328a5cb80b13f0af4de4c3b89f52dee5"
checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a"
dependencies = [
"parse-display-derive",
"regex",
@ -3364,9 +3340,9 @@ dependencies = [
[[package]]
name = "parse-display-derive"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc9252f259500ee570c75adcc4e317fa6f57a1e47747d622e0bf838002a7b790"
checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281"
dependencies = [
"proc-macro2",
"quote",
@ -3735,9 +3711,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]]
name = "proc-macro2"
version = "1.0.83"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b33eb56c327dec362a9e55b3ad14f9d2f0904fb5a5b03b513ab5465399e9f43"
checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23"
dependencies = [
"unicode-ident",
]
@ -4299,6 +4275,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebbbdb961df0ad3f2652da8f3fdc4b36122f568f968f45ad3316f26c025c677b"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki 0.102.3",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.7.0"
@ -5042,7 +5031,7 @@ dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.8.13",
"toml 0.8.14",
"version-compare",
]
@ -5195,7 +5184,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.8.13",
"toml 0.8.14",
"walkdir",
]
@ -5253,7 +5242,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml 0.8.13",
"toml 0.8.14",
"walkdir",
]
@ -5530,7 +5519,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror",
"toml 0.8.13",
"toml 0.8.14",
"url",
"urlpattern",
"walkdir",
@ -5664,9 +5653,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.37.0"
version = "1.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
dependencies = [
"backtrace",
"bytes",
@ -5684,9 +5673,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",
@ -5715,18 +5704,29 @@ dependencies = [
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
name = "tokio-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
dependencies = [
"rustls 0.23.7",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "becd34a233e7e31a3dbf7c7241b38320f57393dcae8e7324b0167d21b8e320b0"
dependencies = [
"futures-util",
"log",
"rustls 0.22.4",
"rustls 0.23.7",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tokio-rustls 0.26.0",
"tungstenite",
]
@ -5758,14 +5758,14 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.13"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba"
checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit 0.22.13",
"toml_edit 0.22.14",
]
[[package]]
@ -5814,9 +5814,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.22.13"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c"
checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38"
dependencies = [
"indexmap 2.2.6",
"serde",
@ -6035,9 +6035,9 @@ dependencies = [
[[package]]
name = "tungstenite"
version = "0.21.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
@ -6046,11 +6046,10 @@ dependencies = [
"httparse",
"log",
"rand 0.8.5",
"rustls 0.22.4",
"rustls 0.23.7",
"rustls-pki-types",
"sha1",
"thiserror",
"url",
"utf-8",
]

View File

@ -0,0 +1,199 @@
import toast from 'react-hot-toast'
import { ActionIcon, ActionIconProps } from './ActionIcon'
import { RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { Dialog } from '@headlessui/react'
interface ContextMenuProps
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
items?: React.ReactElement[]
menuTargetElement?: RefObject<HTMLElement>
}
const DefaultContextMenuItems = [
<ContextMenuItemRefresh />,
<ContextMenuItemCopy />,
// add more default context menu items here
]
export function ContextMenu({
items = DefaultContextMenuItems,
menuTargetElement,
className,
...props
}: ContextMenuProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [windowSize, setWindowSize] = useState({
width: globalThis?.window?.innerWidth,
height: globalThis?.window?.innerHeight,
})
const [position, setPosition] = useState({ x: 0, y: 0 })
useHotkeys('esc', () => setOpen(false), {
enabled: open,
})
const dialogPositionStyle = useMemo(() => {
if (!dialogRef.current)
return {
top: 0,
left: 0,
right: 'auto',
bottom: 'auto',
}
return {
top:
position.y + dialogRef.current.clientHeight > windowSize.height
? 'auto'
: position.y,
left:
position.x + dialogRef.current.clientWidth > windowSize.width
? 'auto'
: position.x,
right:
position.x + dialogRef.current.clientWidth > windowSize.width
? windowSize.width - position.x
: 'auto',
bottom:
position.y + dialogRef.current.clientHeight > windowSize.height
? windowSize.height - position.y
: 'auto',
}
}, [position, windowSize, dialogRef.current])
// Listen for window resize to update context menu position
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: globalThis?.window?.innerWidth,
height: globalThis?.window?.innerHeight,
})
}
globalThis?.window?.addEventListener('resize', handleResize)
return () => {
globalThis?.window?.removeEventListener('resize', handleResize)
}
}, [])
// 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
)
return () => {
menuTargetElement?.current?.removeEventListener(
'contextmenu',
handleContextMenu
)
}
}, [menuTargetElement?.current])
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<div
className="fixed inset-0 z-50 w-screen h-screen"
onContextMenu={(e) => e.preventDefault()}
>
<Dialog.Backdrop className="fixed z-10 inset-0" />
<Dialog.Panel
ref={dialogRef}
className={`w-48 fixed bg-chalkboard-10 dark:bg-chalkboard-90
border border-solid border-chalkboard-10 dark:border-chalkboard-90 rounded
shadow-lg backdrop:fixed backdrop:inset-0 backdrop:bg-primary ${className}`}
style={{
...dialogPositionStyle,
...props.style,
}}
>
<ul
{...props}
className="relative flex flex-col gap-0.5 items-stretch content-stretch"
onClick={() => setOpen(false)}
>
{...items}
</ul>
</Dialog.Panel>
</div>
</Dialog>
)
}
export function ContextMenuDivider() {
return <hr className="border-chalkboard-20 dark:border-chalkboard-80" />
}
interface ContextMenuItemProps {
children: React.ReactNode
icon?: ActionIconProps['icon']
onClick?: () => void
hotkey?: string
}
export function ContextMenuItem({
children,
icon,
onClick,
hotkey,
}: ContextMenuItemProps) {
return (
<button
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
onClick={onClick}
>
{icon && <ActionIcon icon={icon} bgClassName="!bg-transparent" />}
<div className="flex-1">{children}</div>
{hotkey && (
<kbd className="px-1.5 py-0.5 rounded bg-primary/10 text-primary dark:bg-chalkboard-80 dark:text-chalkboard-40">
{hotkey}
</kbd>
)}
</button>
)
}
export function ContextMenuItemRefresh() {
return (
<ContextMenuItem
icon="arrowRotateRight"
onClick={() => globalThis?.window?.location.reload()}
>
Refresh
</ContextMenuItem>
)
}
interface ContextMenuItemCopyProps {
toBeCopiedContent?: string
toBeCopiedLabel?: string
}
export function ContextMenuItemCopy({
toBeCopiedContent = globalThis.window?.getSelection()?.toString(),
toBeCopiedLabel = 'selection',
}: ContextMenuItemCopyProps) {
return (
<ContextMenuItem
icon="clipboardPlus"
onClick={() => {
if (toBeCopiedContent) {
globalThis?.navigator?.clipboard
.writeText(toBeCopiedContent)
.then(() => toast.success(`Copied ${toBeCopiedLabel} to clipboard`))
.catch(() =>
toast.error(`Failed to copy ${toBeCopiedLabel} to clipboard`)
)
}
}}
>
Copy
</ContextMenuItem>
)
}

View File

@ -18,6 +18,8 @@ import { useLspContext } from './LspProvider'
import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { useModelingContext } from 'hooks/useModelingContext'
import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog'
import { ContextMenu, ContextMenuItem } from './ContextMenu'
import usePlatform from 'hooks/usePlatform'
function getIndentationCSS(level: number) {
return `calc(1rem * ${level + 1})`
@ -125,6 +127,7 @@ const FileTreeItem = ({
const navigate = useNavigate()
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const isCurrentFile = fileOrDir.path === currentFile?.path
const itemRef = useRef(null)
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
const removeCurrentItemFromRenaming = useCallback(
@ -185,7 +188,7 @@ const FileTreeItem = ({
}
return (
<>
<div className="contents" ref={itemRef}>
{fileOrDir.children === undefined ? (
<li
className={
@ -321,7 +324,41 @@ const FileTreeItem = ({
setIsOpen={setIsConfirmingDelete}
/>
)}
</>
<FileTreeContextMenu
itemRef={itemRef}
onRename={addCurrentItemToRenaming}
onDelete={() => setIsConfirmingDelete(true)}
/>
</div>
)
}
interface FileTreeContextMenuProps {
itemRef: React.RefObject<HTMLElement>
onRename: () => void
onDelete: () => void
}
function FileTreeContextMenu({
itemRef,
onRename,
onDelete,
}: FileTreeContextMenuProps) {
const platform = usePlatform()
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
return (
<ContextMenu
menuTargetElement={itemRef}
items={[
<ContextMenuItem onClick={onRename} hotkey="Enter">
Rename
</ContextMenuItem>,
<ContextMenuItem onClick={onDelete} hotkey={metaKey + ' + Del'}>
Delete
</ContextMenuItem>,
]}
/>
)
}

View File

@ -19,6 +19,12 @@ import {
Intersection,
Object3D,
} from 'three'
import {
ContextMenu,
ContextMenuDivider,
ContextMenuItem,
ContextMenuItemRefresh,
} from './ContextMenu'
const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5
@ -38,8 +44,17 @@ enum AxisNames {
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() {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const raycasterIntersect = useRef<Intersection<Object3D> | null>(null)
const cameraPassiveUpdateTimer = useRef(0)
@ -100,9 +115,43 @@ export default function Gizmo() {
}, [])
return (
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none">
<>
<div
ref={wrapperRef}
aria-label="View orientation gizmo"
className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-auto"
>
<canvas ref={canvasRef} />
<ContextMenu
menuTargetElement={wrapperRef}
items={[
...Object.entries(axisNamesSemantic).map(
([axisName, axisSemantic]) => (
<ContextMenuItem
key={axisName}
onClick={() => {
sceneInfra.camControls.updateCameraToAxis(
axisName as AxisNames
)
}}
>
{axisSemantic} view
</ContextMenuItem>
)
),
<ContextMenuItem
onClick={() => {
sceneInfra.camControls.resetCameraPosition()
}}
>
Reset view
</ContextMenuItem>,
<ContextMenuDivider />,
<ContextMenuItemRefresh />,
]}
/>
</div>
</>
)
}