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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user