Skip to content

Commit 4d7db31

Browse files
committed
Merge branch '136-mobile-context-menu' into 'main'
Enable to open context menu with long touch press Closes #136 See merge request ExplorViz/code/frontend!259
2 parents 1b91d32 + 3b7166d commit 4d7db31

File tree

3 files changed

+114
-2
lines changed

3 files changed

+114
-2
lines changed

src/components/context-menu.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Position2D } from '../hooks/interaction-modifier';
77
import { removeAllHighlighting } from 'explorviz-frontend/src/utils/application-rendering/highlighting';
88
import { useAnnotationHandlerStore } from 'explorviz-frontend/src/stores/annotation-handler';
99
import { pingByModelId } from 'explorviz-frontend/src/view-objects/3d/application/animated-ping-r3f';
10+
import useLongPress from '../hooks/useLongPress';
1011
export type ContextMenuItem = {
1112
title: string;
1213
action: () => void;
@@ -27,6 +28,14 @@ export default function ContextMenu({ children, enterVR }: ContextMenuProps) {
2728

2829
const hide = () => setVisible(false);
2930

31+
const handleLongPress = (pos: Position2D) => {
32+
setPosition(pos);
33+
reveal();
34+
};
35+
36+
const { onTouchStart, onTouchMove, onTouchEnd } =
37+
useLongPress(handleLongPress);
38+
3039
const resetView = async () => {
3140
useCameraControlsStore.getState().resetCamera();
3241
};
@@ -128,11 +137,14 @@ export default function ContextMenu({ children, enterVR }: ContextMenuProps) {
128137
onMouseUp={onMouseUp}
129138
onMouseDown={onMouseDown}
130139
onContextMenu={onContextMenu}
140+
onTouchStart={onTouchStart}
141+
onTouchMove={onTouchMove}
142+
onTouchEnd={onTouchEnd}
131143
style={{ width: '100%' }}
132144
>
133145
{visible && position && (
134146
<ul
135-
className="bg-white shadow border rounded-md w-40"
147+
className="bg-white shadow border rounded-md w-40 select-none"
136148
style={{
137149
position: 'absolute',
138150
top: position.y,
@@ -164,7 +176,7 @@ interface ContextMenuItemProps {
164176
function ContextMenuItem({ title, onSelect }: ContextMenuItemProps) {
165177
return (
166178
<li
167-
className="context-menu-item"
179+
className="context-menu-item select-none"
168180
style={{ cursor: 'pointer' }}
169181
onClick={onSelect}
170182
>

src/hooks/useLongPress.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useEffect, useRef } from 'react';
2+
import { Position2D } from './interaction-modifier';
3+
4+
interface UseLongPressOptions {
5+
delay?: number;
6+
movementThreshold?: number;
7+
}
8+
9+
const useLongPress = (
10+
onLongPress: (position: Position2D) => void,
11+
options: UseLongPressOptions = {}
12+
) => {
13+
const { delay: LONG_PRESS_DELAY = 500, movementThreshold = 35 } = options;
14+
15+
const touchMoved = useRef<boolean>(false);
16+
const longPressTimer = useRef<NodeJS.Timeout | null>(null);
17+
const touchStartPosition = useRef<Position2D | null>(null);
18+
const longPressTriggered = useRef<boolean>(false);
19+
20+
const onTouchStart = (event: React.TouchEvent) => {
21+
touchMoved.current = false;
22+
longPressTriggered.current = false;
23+
const touch = event.touches[0];
24+
touchStartPosition.current = { x: touch.pageX, y: touch.pageY };
25+
26+
// Clear any existing timer
27+
if (longPressTimer.current) {
28+
clearTimeout(longPressTimer.current);
29+
}
30+
31+
longPressTimer.current = setTimeout(() => {
32+
if (!touchMoved.current && touchStartPosition.current) {
33+
longPressTriggered.current = true;
34+
onLongPress({
35+
x: touchStartPosition.current.x,
36+
y: touchStartPosition.current.y,
37+
});
38+
}
39+
}, LONG_PRESS_DELAY);
40+
};
41+
42+
const onTouchMove = (event: React.TouchEvent) => {
43+
if (touchStartPosition.current && event.touches[0]) {
44+
const touch = event.touches[0];
45+
const deltaX = Math.abs(touch.pageX - touchStartPosition.current.x);
46+
const deltaY = Math.abs(touch.pageY - touchStartPosition.current.y);
47+
48+
// If moved more than threshold, cancel long press
49+
if (deltaX > movementThreshold || deltaY > movementThreshold) {
50+
touchMoved.current = true;
51+
longPressTriggered.current = false;
52+
if (longPressTimer.current) {
53+
clearTimeout(longPressTimer.current);
54+
longPressTimer.current = null;
55+
}
56+
}
57+
}
58+
};
59+
60+
const onTouchEnd = (event: React.TouchEvent) => {
61+
// Cancel long press if touch ends before timer completes
62+
if (longPressTimer.current) {
63+
clearTimeout(longPressTimer.current);
64+
longPressTimer.current = null;
65+
}
66+
67+
// Prevent default context menu if long press was successfully triggered
68+
if (longPressTriggered.current) {
69+
event.preventDefault();
70+
}
71+
72+
touchStartPosition.current = null;
73+
longPressTriggered.current = false;
74+
};
75+
76+
useEffect(() => {
77+
return () => {
78+
// Cleanup timer on unmount
79+
if (longPressTimer.current) {
80+
clearTimeout(longPressTimer.current);
81+
longPressTimer.current = null;
82+
}
83+
};
84+
}, []);
85+
86+
return {
87+
onTouchStart,
88+
onTouchMove,
89+
onTouchEnd,
90+
};
91+
};
92+
93+
export default useLongPress;

src/scss/_context_menu.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@
66
background-color: lightgrey;
77
}
88
}
9+
10+
.select-none {
11+
user-select: none;
12+
-webkit-user-select: none;
13+
-moz-user-select: none;
14+
-ms-user-select: none;
15+
}

0 commit comments

Comments
 (0)