mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
more tweaks
This commit is contained in:
@@ -6,6 +6,22 @@ import { useChat } from '../../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
|
||||
function detectSafari() {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const userAgent = navigator.userAgent || '';
|
||||
const vendor = navigator.vendor || '';
|
||||
|
||||
/*
|
||||
Safari is the browser that needs the 16px focused-input workaround. Chrome
|
||||
and Firefox on iOS also use WebKit internally, but they identify themselves
|
||||
in the user agent, so leave their HUD chat sizing alone unless they report
|
||||
as Safari proper.
|
||||
*/
|
||||
return /safari/i.test(userAgent) &&
|
||||
/apple/i.test(vendor) &&
|
||||
!/crios|fxios|edgios|opr|chrome|android/i.test(userAgent);
|
||||
}
|
||||
|
||||
function HudChatInput({ compact = false }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
@@ -56,12 +72,13 @@ function HudChatInput({ compact = false }) {
|
||||
const containerClass = compact
|
||||
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
|
||||
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
||||
// Safari zooms focused inputs below 16px. The dedicated mobile-text-entry
|
||||
// utility preserves normal text editing while keeping focus from changing the
|
||||
// page zoom when the compact chat field is used over the camera feed.
|
||||
const inputClass = compact
|
||||
? 'mobile-text-entry min-w-0 flex-1 bg-transparent text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
||||
: 'mobile-text-entry min-w-0 flex-1 bg-transparent text-slate-100 placeholder:text-slate-400 focus:outline-none';
|
||||
const isSafari = useMemo(() => detectSafari(), []);
|
||||
/*
|
||||
Safari zooms focused inputs below 16px. Keep that workaround Safari-only so
|
||||
other mobile browsers keep the deliberately tiny HUD typography.
|
||||
*/
|
||||
const inputTextClass = isSafari ? 'mobile-text-entry' : compact ? 'text-[0.55rem]' : 'text-[0.7rem]';
|
||||
const inputClass = `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`;
|
||||
// The submit button is still a touch target even though the adjacent input must
|
||||
// remain editable, so it gets press suppression without inheriting input text
|
||||
// selection behavior.
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
|
||||
import NightVisionControl from '../NightVisionControl/index.jsx';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
import CameraTiltControl from '../CameraTiltControl/index.jsx';
|
||||
import FloatingJoystick from './FloatingJoystick.jsx';
|
||||
import MobileAuxButton from './MobileAuxButton.jsx';
|
||||
import {
|
||||
@@ -37,6 +36,134 @@ function getSpeedModeConfig(speedMode) {
|
||||
return DRIVE_PAD_SPEED_MODES.find((mode) => mode.id === speedMode) || DRIVE_PAD_SPEED_MODES[1];
|
||||
}
|
||||
|
||||
function clampCameraAngle(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function formatCameraAngle(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
function MobileCameraTiltSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 0.5,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}) {
|
||||
const trackRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
|
||||
const valuePercent = useMemo(() => {
|
||||
if (max === min) return 50;
|
||||
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
|
||||
}, [max, min, value]);
|
||||
const disabledClass = disabled ? 'opacity-50' : '';
|
||||
|
||||
const valueFromPointer = useCallback(
|
||||
(event) => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return value;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const usableHeight = Math.max(1, rect.height);
|
||||
const rawPercent = 1 - (event.clientY - rect.top) / usableHeight;
|
||||
const unclamped = min + clampCameraAngle(rawPercent, 0, 1) * (max - min);
|
||||
const stepped = Math.round(unclamped / step) * step;
|
||||
return clampCameraAngle(stepped, min, max);
|
||||
},
|
||||
[max, min, step, value],
|
||||
);
|
||||
|
||||
const sendPointerValue = useCallback(
|
||||
(event) => {
|
||||
const next = valueFromPointer(event);
|
||||
onChange?.(next);
|
||||
},
|
||||
[onChange, valueFromPointer],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event) => {
|
||||
if (disabled) return;
|
||||
if (pointerIdRef.current !== null) return;
|
||||
|
||||
/*
|
||||
Native vertical range inputs are unreliable as a second simultaneous
|
||||
touch on mobile Safari while the drive pad owns another active pointer.
|
||||
This custom track captures only the camera finger, so the drive thumb can
|
||||
continue moving without stealing or cancelling camera tilt updates.
|
||||
*/
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
trackRef.current?.setPointerCapture?.(event.pointerId);
|
||||
sendPointerValue(event);
|
||||
},
|
||||
[disabled, sendPointerValue],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
sendPointerValue(event);
|
||||
},
|
||||
[sendPointerValue],
|
||||
);
|
||||
|
||||
const handlePointerEnd = useCallback((event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = null;
|
||||
trackRef.current?.releasePointerCapture?.(event.pointerId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
|
||||
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
|
||||
Camera tilt
|
||||
</span>
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
aria-label="Camera tilt"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={Number.isFinite(value) ? value : min}
|
||||
aria-valuetext={formatCameraAngle(value)}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/*
|
||||
touchAction stays inline as a second line of defense because this is the
|
||||
element that must keep browser panning/zooming out of the multi-touch
|
||||
camera gesture.
|
||||
*/
|
||||
style={{ touchAction: 'none' }}
|
||||
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"
|
||||
style={{ height: `${valuePercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 h-3.5 w-3.5 -translate-x-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
||||
style={{ bottom: `clamp(0.25rem, calc(${valuePercent}% - 0.4375rem), calc(100% - 1.125rem))` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId, keymap: rawKeymap },
|
||||
@@ -297,23 +424,13 @@ function MobileActionsColumnContent() {
|
||||
// Match the desktop camera tilt card's emerald styling so the vertical
|
||||
// mobile control reads as the same feature in a phone-sized layout.
|
||||
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
|
||||
<CameraTiltControl
|
||||
<MobileCameraTiltSlider
|
||||
value={cameraValue}
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={0.5}
|
||||
disabled={cameraDisabled}
|
||||
onChange={setServoAngle}
|
||||
orientation="vertical"
|
||||
label="Camera tilt"
|
||||
labelClass="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180"
|
||||
labelRowClass="mobile-touch-control text-[0.7rem] text-emerald-100"
|
||||
valueClass="font-mono text-slate-100"
|
||||
className="h-full gap-0"
|
||||
sliderClass="mobile-touch-control mobile-drag-control h-full w-7"
|
||||
accentClass="accent-emerald-400"
|
||||
showEndpoints={false}
|
||||
showValue={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Night Vision Control
|
||||
// Purpose: Defines the Night Vision Control module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
function isBoolean(value) {
|
||||
return typeof value === 'boolean';
|
||||
@@ -18,6 +18,7 @@ export default function NightVisionControl({
|
||||
const [optimistic, setOptimistic] = useState(
|
||||
isBoolean(nightVisionOn) ? nightVisionOn : null,
|
||||
);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBoolean(nightVisionOn)) {
|
||||
@@ -39,6 +40,32 @@ export default function NightVisionControl({
|
||||
onToggle?.(next);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
if (disabled) return;
|
||||
if (event.pointerType === 'mouse') return;
|
||||
|
||||
/*
|
||||
Mobile browsers, especially Safari, do not always dispatch a reliable
|
||||
synthetic click for a second finger while another finger is held on the
|
||||
drive pad. Toggle on the real touch pointerdown instead, then suppress the
|
||||
follow-up click so one tap cannot flip night vision twice.
|
||||
*/
|
||||
event.preventDefault();
|
||||
suppressClickRef.current = true;
|
||||
handleToggle();
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (suppressClickRef.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
handleToggle();
|
||||
};
|
||||
|
||||
const buttonClasses = useMemo(() => {
|
||||
// Night vision is used as a direct mobile press target, so selection and
|
||||
// Safari callout suppression live on the button itself rather than only on
|
||||
@@ -55,7 +82,8 @@ export default function NightVisionControl({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
disabled={disabled}
|
||||
aria-pressed={displayOn}
|
||||
|
||||
Reference in New Issue
Block a user