more tweaks

This commit is contained in:
legop3
2026-06-10 14:44:23 -04:00
parent 426fd3e7c4
commit 4fd44d6ad6
7 changed files with 194 additions and 32 deletions
@@ -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}