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:
@@ -6,6 +6,7 @@ import { SpecialKeysPanel } from "./special-keys-panel";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface MobileTerminalWrapperProps {
|
||||
instanceId: string;
|
||||
@@ -26,6 +27,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||
const [terminalRef, setTerminalRef] = useState<{
|
||||
sendData: (data: string) => void;
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
|
||||
@@ -84,6 +86,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
instanceId={instanceId}
|
||||
onClose={onClose}
|
||||
isMobile={true}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
@@ -93,6 +97,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
isVisible={!showPanel}
|
||||
onMoreClick={() => setShowPanel(true)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
|
||||
<SpecialKeysPanel
|
||||
@@ -100,6 +106,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
isOpen={showPanel}
|
||||
onClose={() => setShowPanel(false)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 {
|
||||
onSend: (data: string) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
activeModifier: ModifierKey | null;
|
||||
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||
}
|
||||
|
||||
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
@@ -38,14 +40,22 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onKeepFocus,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
}) => {
|
||||
const { sendKey, clearModifier } = useSpecialKeys({ onSend });
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
sendKey(key);
|
||||
|
||||
const result = getSequenceWithModifier(key, activeModifier);
|
||||
if (result) {
|
||||
onSend(result.sequence);
|
||||
if (result.clearModifier) {
|
||||
onModifierChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
onClose();
|
||||
// Always refocus terminal after sending
|
||||
requestAnimationFrame(() => {
|
||||
@@ -55,7 +65,7 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
|
||||
const handleOverlayPointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
clearModifier();
|
||||
onModifierChange(null);
|
||||
onClose();
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 {
|
||||
onSend: (data: string) => void;
|
||||
isVisible: boolean;
|
||||
onMoreClick?: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
activeModifier: ModifierKey | null;
|
||||
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||
}
|
||||
|
||||
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
|
||||
@@ -24,12 +26,29 @@ export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||
isVisible,
|
||||
onMoreClick,
|
||||
onKeepFocus,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
}) => {
|
||||
const { sendKey, activeModifier } = useSpecialKeys({ onSend });
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
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
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
|
||||
@@ -4,10 +4,14 @@ import { FitAddon } from "xterm-addon-fit";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
isMobile?: boolean;
|
||||
activeModifier?: ModifierKey | null;
|
||||
onModifierChange?: (modifier: ModifierKey | null) => void;
|
||||
onTerminalReady?: (
|
||||
sendData: (data: string) => void,
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
|
||||
@@ -25,6 +29,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
instanceId,
|
||||
onClose,
|
||||
isMobile = false,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
onTerminalReady,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -39,6 +45,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
"connecting" | "connected" | "disconnected" | "error"
|
||||
>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const activeModifierRef = useRef(activeModifier);
|
||||
activeModifierRef.current = activeModifier;
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
||||
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||
@@ -161,9 +169,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
|
||||
export type SpecialKey =
|
||||
| "escape"
|
||||
@@ -103,47 +103,38 @@ const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: st
|
||||
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
|
||||
};
|
||||
|
||||
interface UseSpecialKeysOptions {
|
||||
onSend: (data: string) => void;
|
||||
export function getSequenceWithModifier(
|
||||
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) {
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||
|
||||
const sendKey = useCallback(
|
||||
(key: SpecialKey) => {
|
||||
// Handle modifier keys (one-shot)
|
||||
if (key === "ctrl" || key === "alt") {
|
||||
// Toggle 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 function applyModifierToChar(
|
||||
char: string,
|
||||
modifier: ModifierKey
|
||||
): string | null {
|
||||
if (char.length !== 1) return null;
|
||||
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
|
||||
if (!mapping) return null;
|
||||
return mapping[modifier];
|
||||
}
|
||||
|
||||
export { KEY_SEQUENCES };
|
||||
|
||||
Reference in New Issue
Block a user