mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v2.3.0
This commit is contained in:
@@ -1,16 +1,28 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "@/main-axios.ts";
|
||||
import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
||||
hostId,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -18,20 +30,46 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<RefreshCw className="size-8 animate-spin" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest">
|
||||
{t("common.loading")}
|
||||
</span>
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader visible={true} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -40,8 +78,10 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
|
||||
return (
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId!, 10)}
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -52,51 +92,154 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<SSHHost, "connectionType">;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
hostId,
|
||||
hostConfig,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getGuacamoleTokenFromHost(hostId)
|
||||
.then((result) => setToken(result.token))
|
||||
setToken(null);
|
||||
setError(null);
|
||||
getGuacdStatus()
|
||||
.then((status) => {
|
||||
if (status.guacd.status !== "connected") {
|
||||
setError(t("guacamole.guacdUnavailable"));
|
||||
return;
|
||||
}
|
||||
return getGuacamoleTokenFromHost(hostId, protocol);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) setToken(result.token);
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId]);
|
||||
}, [hostId, retryCount]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
const handler = (e: Event) => {
|
||||
const { tabId: eventTabId } = (e as CustomEvent).detail;
|
||||
if (eventTabId === tabId) handleReconnect();
|
||||
};
|
||||
window.addEventListener("termix:refresh-guacamole", handler);
|
||||
return () =>
|
||||
window.removeEventListener("termix:refresh-guacamole", handler);
|
||||
}, [tabId, handleReconnect]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<span className="text-sm font-semibold text-destructive">{error}</span>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<RefreshCw className="size-8 animate-spin" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest">
|
||||
{t("guacamole.connecting", {
|
||||
type: (hostConfig.connectionType || "remote").toUpperCase(),
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("guacamole.connecting", {
|
||||
type: (
|
||||
protocol ||
|
||||
hostConfig.connectionType ||
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
})}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{connectionError && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{connectionError}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol }}
|
||||
key={token}
|
||||
ref={displayRef}
|
||||
connectionConfig={{
|
||||
token,
|
||||
protocol: resolvedProtocol,
|
||||
type: resolvedProtocol,
|
||||
}}
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
@@ -267,13 +268,20 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsReady(false);
|
||||
setHasError(false);
|
||||
|
||||
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||
let containerHeight = containerRef.current?.clientHeight || 0;
|
||||
// Wait two frames so the container is fully laid out before measuring.
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
let containerWidth = rect?.width || 0;
|
||||
let containerHeight = rect?.height || 0;
|
||||
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
containerWidth = 1280;
|
||||
containerHeight = 720;
|
||||
containerWidth = window.innerWidth || 1280;
|
||||
containerHeight = window.innerHeight || 720;
|
||||
}
|
||||
|
||||
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||
@@ -303,6 +311,11 @@ export const GuacamoleDisplay = forwardRef<
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (protocol === "telnet") {
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
@@ -351,8 +364,15 @@ export const GuacamoleDisplay = forwardRef<
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
isConnectingRef.current = false;
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const w = Math.round(rect.width);
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) client.sendSize(w, h);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
@@ -366,8 +386,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
const errorMessage = error.message || "Connection error";
|
||||
const errorMessage = error.message || t("guacamole.connectionError");
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
isConnectingRef.current = false;
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
@@ -388,6 +410,24 @@ export const GuacamoleDisplay = forwardRef<
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
filename: string,
|
||||
) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
const blob = reader.getBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
|
||||
};
|
||||
|
||||
client.connect();
|
||||
}, [
|
||||
getWebSocketUrl,
|
||||
@@ -407,11 +447,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
if (isVisible && !hasInitiatedRef.current) {
|
||||
hasInitiatedRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (isMountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
connect();
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
@@ -475,26 +511,23 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
rescaleDisplay(false);
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const w = containerRef.current.clientWidth;
|
||||
const h = containerRef.current.clientHeight;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const w = Math.round(rect.width);
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
|
||||
}
|
||||
}, 200);
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
clearTimeout(initialTimeout);
|
||||
};
|
||||
}, [rescaleDisplay]);
|
||||
}, []);
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
@@ -553,7 +586,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleLoader visible={!isReady} message={connectingMessage} />
|
||||
<SimpleLoader
|
||||
visible={!isReady && !hasError}
|
||||
message={connectingMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Monitor,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsLeftRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip.tsx";
|
||||
import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
onReconnect: () => void;
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
ctrl: 0xffe3,
|
||||
alt: 0xffe9,
|
||||
shift: 0xffe1,
|
||||
win: 0xff67,
|
||||
} as const;
|
||||
|
||||
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
|
||||
|
||||
const BTN_BASE =
|
||||
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
|
||||
|
||||
const BTN_ICON =
|
||||
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
|
||||
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
function TipBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(BTN_BASE, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function TipIconBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(BTN_ICON, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
onReconnect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: 0, y: 12 });
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showFKeys, setShowFKeys] = useState(false);
|
||||
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
|
||||
[MODIFIER_KEYSYMS.ctrl]: false,
|
||||
[MODIFIER_KEYSYMS.alt]: false,
|
||||
[MODIFIER_KEYSYMS.shift]: false,
|
||||
[MODIFIER_KEYSYMS.win]: false,
|
||||
});
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
const parent = el.offsetParent as HTMLElement | null;
|
||||
if (!parent) return;
|
||||
const parentW = parent.clientWidth;
|
||||
const toolbarW = el.offsetWidth;
|
||||
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
|
||||
const parentW = parent?.clientWidth ?? Infinity;
|
||||
const parentH = parent?.clientHeight ?? Infinity;
|
||||
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
|
||||
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
|
||||
|
||||
const dx = e.clientX - dragOriginRef.current.mouseX;
|
||||
const dy = e.clientY - dragOriginRef.current.mouseY;
|
||||
setPosition({
|
||||
x: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
dragOriginRef.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
};
|
||||
document.body.style.userSelect = "none";
|
||||
setIsDragging(true);
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const releaseStickyKeys = useCallback(() => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [ksStr, active] of Object.entries(prev)) {
|
||||
if (active) {
|
||||
display.sendKey(Number(ksStr), false);
|
||||
next[Number(ksStr)] = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [displayRef]);
|
||||
|
||||
const sendCombo = useCallback(
|
||||
(...keysyms: number[]) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
for (const k of keysyms) display.sendKey(k, true);
|
||||
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
|
||||
releaseStickyKeys();
|
||||
},
|
||||
[displayRef, releaseStickyKeys],
|
||||
);
|
||||
|
||||
const toggleStickyKey = useCallback(
|
||||
(keysym: number) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const isActive = prev[keysym];
|
||||
display.sendKey(keysym, !isActive);
|
||||
return { ...prev, [keysym]: !isActive };
|
||||
});
|
||||
},
|
||||
[displayRef],
|
||||
);
|
||||
|
||||
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 20,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
style={containerStyle}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.expand")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
|
||||
{/* Drag handle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.dragHandle")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* System combos — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.ctrlAltDel")}
|
||||
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
||||
>
|
||||
CAD
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.winL")}
|
||||
onClick={() => sendCombo(0xff67, 0x006c)}
|
||||
>
|
||||
Win+L
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.winKey")}
|
||||
onClick={() => sendCombo(0xff67)}
|
||||
>
|
||||
Win
|
||||
</TipBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sticky modifiers — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
{(
|
||||
[
|
||||
[
|
||||
"ctrl",
|
||||
MODIFIER_KEYSYMS.ctrl,
|
||||
t("guacamole.toolbar.ctrl"),
|
||||
],
|
||||
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
|
||||
[
|
||||
"shift",
|
||||
MODIFIER_KEYSYMS.shift,
|
||||
t("guacamole.toolbar.shift"),
|
||||
],
|
||||
["win", MODIFIER_KEYSYMS.win, t("guacamole.toolbar.win")],
|
||||
] as [string, number, string][]
|
||||
).map(([key, ks, label]) => (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStickyKey(ks)}
|
||||
className={cn(
|
||||
BTN_BASE,
|
||||
stickyKeys[ks] &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{stickyKeys[ks]
|
||||
? t("guacamole.toolbar.stickyActive", { key: label })
|
||||
: t("guacamole.toolbar.stickyInactive", { key: label })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Function key toggle */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.fnToggle")}
|
||||
onClick={() => setShowFKeys((v) => !v)}
|
||||
className={cn(
|
||||
showFKeys &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
Fn
|
||||
</TipBtn>
|
||||
|
||||
{/* F1-F12 row */}
|
||||
{showFKeys &&
|
||||
FKEY_KEYSYMS.map((ks, i) => (
|
||||
<TipBtn
|
||||
key={ks}
|
||||
tooltip={`F${i + 1}`}
|
||||
onClick={() => sendCombo(ks)}
|
||||
>
|
||||
F{i + 1}
|
||||
</TipBtn>
|
||||
))}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.esc")}
|
||||
onClick={() => sendCombo(0xff1b)}
|
||||
>
|
||||
Esc
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.tab")}
|
||||
onClick={() => sendCombo(0xff09)}
|
||||
>
|
||||
Tab
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.home")}
|
||||
onClick={() => sendCombo(0xff50)}
|
||||
>
|
||||
Home
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.end")}
|
||||
onClick={() => sendCombo(0xff57)}
|
||||
>
|
||||
End
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageUp")}
|
||||
onClick={() => sendCombo(0xff55)}
|
||||
>
|
||||
PgUp
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageDown")}
|
||||
onClick={() => sendCombo(0xff56)}
|
||||
>
|
||||
PgDn
|
||||
</TipBtn>
|
||||
|
||||
{/* Arrow cluster */}
|
||||
<div className="flex flex-col ml-0.5">
|
||||
<div className="flex justify-center">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowUp")}
|
||||
onClick={() => sendCombo(0xff52)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowLeft")}
|
||||
onClick={() => sendCombo(0xff51)}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowDown")}
|
||||
onClick={() => sendCombo(0xff54)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowRight")}
|
||||
onClick={() => sendCombo(0xff53)}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapse */}
|
||||
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
<ChevronsLeftRight className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user