fix: lift modifier state to MobileTerminalWrapper for virtual keyboard integration

- Remove useSpecialKeys hook state, export pure utility functions instead
- MobileTerminalWrapper now owns activeModifier state
- SpecialKeysStrip and SpecialKeysPanel receive modifier via props
- TerminalComponent applies modifier to virtual keyboard input via activeModifier prop
- Modifier now works with both special keys AND virtual keyboard input
- Modifier clears after any key press (special or virtual keyboard)
This commit is contained in:
Fusion
2026-05-24 12:45:07 +02:00
parent 3d9ff44d1a
commit e555561a2d
5 changed files with 98 additions and 51 deletions
@@ -6,6 +6,7 @@ import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard"; import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide"; import { useAutoHide } from "../hooks/use-auto-hide";
import type { ModifierKey } from "../hooks/use-special-keys";
interface MobileTerminalWrapperProps { interface MobileTerminalWrapperProps {
instanceId: string; instanceId: string;
@@ -26,6 +27,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
const { isOpen: isKeyboardOpen, height: keyboardHeight } = const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard(); useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false); const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{ const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void; sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error"; connectionStatus: "connecting" | "connected" | "disconnected" | "error";
@@ -84,6 +86,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId={instanceId} instanceId={instanceId}
onClose={onClose} onClose={onClose}
isMobile={true} isMobile={true}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady} onTerminalReady={handleTerminalReady}
/> />
</div> </div>
@@ -93,6 +97,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
isVisible={!showPanel} isVisible={!showPanel}
onMoreClick={() => setShowPanel(true)} onMoreClick={() => setShowPanel(true)}
onKeepFocus={() => terminalRef?.focusInput()} onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/> />
<SpecialKeysPanel <SpecialKeysPanel
@@ -100,6 +106,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
isOpen={showPanel} isOpen={showPanel}
onClose={() => setShowPanel(false)} onClose={() => setShowPanel(false)}
onKeepFocus={() => terminalRef?.focusInput()} onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/> />
</div> </div>
); );
+15 -5
View File
@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys"; import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps { interface SpecialKeysPanelProps {
onSend: (data: string) => void; onSend: (data: string) => void;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onKeepFocus?: () => void; onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
} }
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [ const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
@@ -38,14 +40,22 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
isOpen, isOpen,
onClose, onClose,
onKeepFocus, onKeepFocus,
activeModifier,
onModifierChange,
}) => { }) => {
const { sendKey, clearModifier } = useSpecialKeys({ onSend });
if (!isOpen) return null; if (!isOpen) return null;
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => { const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault(); e.preventDefault();
sendKey(key);
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
onClose(); onClose();
// Always refocus terminal after sending // Always refocus terminal after sending
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -55,7 +65,7 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
const handleOverlayPointerDown = (e: React.PointerEvent) => { const handleOverlayPointerDown = (e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
clearModifier(); onModifierChange(null);
onClose(); onClose();
requestAnimationFrame(() => { requestAnimationFrame(() => {
onKeepFocus?.(); onKeepFocus?.();
+23 -4
View File
@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { useSpecialKeys, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys"; import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
interface SpecialKeysStripProps { interface SpecialKeysStripProps {
onSend: (data: string) => void; onSend: (data: string) => void;
isVisible: boolean; isVisible: boolean;
onMoreClick?: () => void; onMoreClick?: () => void;
onKeepFocus?: () => void; onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
} }
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [ const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
@@ -24,12 +26,29 @@ export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
isVisible, isVisible,
onMoreClick, onMoreClick,
onKeepFocus, onKeepFocus,
activeModifier,
onModifierChange,
}) => { }) => {
const { sendKey, activeModifier } = useSpecialKeys({ onSend });
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => { const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault(); e.preventDefault();
sendKey(key);
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
onModifierChange(activeModifier === key ? null : key);
requestAnimationFrame(() => {
onKeepFocus?.();
});
return;
}
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
// Always refocus terminal after sending // Always refocus terminal after sending
requestAnimationFrame(() => { requestAnimationFrame(() => {
onKeepFocus?.(); onKeepFocus?.();
+21 -2
View File
@@ -4,10 +4,14 @@ import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links"; import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css"; import "xterm/css/xterm.css";
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
interface TerminalProps { interface TerminalProps {
instanceId: string; instanceId: string;
onClose?: () => void; onClose?: () => void;
isMobile?: boolean; isMobile?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: ( onTerminalReady?: (
sendData: (data: string) => void, sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error", connectionStatus: "connecting" | "connected" | "disconnected" | "error",
@@ -25,6 +29,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId, instanceId,
onClose, onClose,
isMobile = false, isMobile = false,
activeModifier,
onModifierChange,
onTerminalReady, onTerminalReady,
}) => { }) => {
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
@@ -39,6 +45,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
"connecting" | "connected" | "disconnected" | "error" "connecting" | "connected" | "disconnected" | "error"
>("connecting"); >("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => { const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 16 : 14; if (typeof window === "undefined") return isMobile ? 16 : 14;
const stored = localStorage.getItem(FONT_SIZE_KEY); const stored = localStorage.getItem(FONT_SIZE_KEY);
@@ -161,9 +169,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Handle terminal input // Handle terminal input
term.onData((data) => { term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) return;
ws.send(data);
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
ws.send(modified);
onModifierChange?.(null);
return;
}
} }
ws.send(data);
}); });
// Handle resize with debounce // Handle resize with debounce
+31 -40
View File
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
export type SpecialKey = export type SpecialKey =
| "escape" | "escape"
@@ -103,47 +103,38 @@ const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: st
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" }, "9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
}; };
interface UseSpecialKeysOptions { export function getSequenceWithModifier(
onSend: (data: string) => void; key: SpecialKey,
activeModifier: ModifierKey | null
): { sequence: string; clearModifier: boolean } | null {
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
return null; // Modifiers don't send anything themselves
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return null;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
return { sequence: mapping[activeModifier], clearModifier: true };
}
}
return { sequence, clearModifier: !!activeModifier };
} }
export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) { export function applyModifierToChar(
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null); char: string,
modifier: ModifierKey
const sendKey = useCallback( ): string | null {
(key: SpecialKey) => { if (char.length !== 1) return null;
// Handle modifier keys (one-shot) const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (key === "ctrl" || key === "alt") { if (!mapping) return null;
// Toggle modifier return mapping[modifier];
setActiveModifier((current) => (current === key ? null : key));
return;
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
onSend(mapping[activeModifier]);
setActiveModifier(null);
return;
}
}
onSend(sequence);
setActiveModifier(null);
},
[onSend, activeModifier]
);
const clearModifier = useCallback(() => {
setActiveModifier(null);
}, []);
return { sendKey, activeModifier, clearModifier };
} }
export { KEY_SEQUENCES }; export { KEY_SEQUENCES };