Fixes console page responsiveness, allowing proper stretch on different viewports
This commit is contained in:
@@ -34,6 +34,9 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
|
||||
|
||||
const tmpCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const tmpCtxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
const framebufferRef = useRef({ width: 0, height: 0 });
|
||||
|
||||
const [state, setState] = useState<VncConnectionState>("IDLE");
|
||||
@@ -45,12 +48,49 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
const [lastFrameAt, setLastFrameAt] = useState<string | null>(null);
|
||||
|
||||
const buildWebSocketUrl = useCallback(() => {
|
||||
if (!accessToken) {
|
||||
return websocketUrl;
|
||||
return accessToken ? appendAccessToken(websocketUrl, accessToken) : websocketUrl;
|
||||
}, [websocketUrl, accessToken]);
|
||||
|
||||
const renderScaledFrame = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const tmpCanvas = tmpCanvasRef.current;
|
||||
|
||||
if (!canvas || !tmpCanvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const displayWidth = Math.max(1, Math.round(rect.width));
|
||||
const displayHeight = Math.max(1, Math.round(rect.height));
|
||||
|
||||
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
}
|
||||
|
||||
return appendAccessToken(websocketUrl, accessToken);
|
||||
}, [websocketUrl, accessToken]);
|
||||
if (!ctxRef.current) {
|
||||
ctxRef.current = canvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(
|
||||
tmpCanvas,
|
||||
0,
|
||||
0,
|
||||
tmpCanvas.width,
|
||||
tmpCanvas.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearFrame = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -64,6 +104,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
|
||||
ctxRef.current = null;
|
||||
rgbaRef.current = null;
|
||||
tmpCanvasRef.current = null;
|
||||
tmpCtxRef.current = null;
|
||||
framebufferRef.current = { width: 0, height: 0 };
|
||||
|
||||
setFrameSize({ width: 0, height: 0 });
|
||||
@@ -96,63 +138,66 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const drawFrame = useCallback((buffer: ArrayBuffer) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const drawFrame = useCallback(
|
||||
(buffer: ArrayBuffer) => {
|
||||
if (buffer.byteLength <= 8) return;
|
||||
|
||||
if (buffer.byteLength <= 8) return;
|
||||
const view = new DataView(buffer);
|
||||
const width = view.getInt32(0);
|
||||
const height = view.getInt32(4);
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const width = view.getInt32(0);
|
||||
const height = view.getInt32(4);
|
||||
if (!width || !height || width <= 0 || height <= 0) return;
|
||||
|
||||
console.log("[VNC] drawFrame", {
|
||||
byteLength: buffer.byteLength,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const pixels = new Uint8ClampedArray(buffer, 8);
|
||||
|
||||
if (!width || !height || width <= 0 || height <= 0) return;
|
||||
if (
|
||||
framebufferRef.current.width !== width ||
|
||||
framebufferRef.current.height !== height
|
||||
) {
|
||||
framebufferRef.current = { width, height };
|
||||
setFrameSize({ width, height });
|
||||
}
|
||||
|
||||
const pixels = new Uint8ClampedArray(buffer, 8);
|
||||
framebufferRef.current = { width, height };
|
||||
if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) {
|
||||
rgbaRef.current = new Uint8ClampedArray(width * height * 4);
|
||||
}
|
||||
|
||||
if (!ctxRef.current) {
|
||||
ctxRef.current = canvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
}
|
||||
const rgba = rgbaRef.current;
|
||||
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
rgba[index] = pixels[index + 2];
|
||||
rgba[index + 1] = pixels[index + 1];
|
||||
rgba[index + 2] = pixels[index];
|
||||
rgba[index + 3] = 255;
|
||||
}
|
||||
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
rgbaRef.current = new Uint8ClampedArray(width * height * 4);
|
||||
setFrameSize({ width, height });
|
||||
}
|
||||
if (!tmpCanvasRef.current) {
|
||||
tmpCanvasRef.current = document.createElement("canvas");
|
||||
}
|
||||
|
||||
if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) {
|
||||
rgbaRef.current = new Uint8ClampedArray(width * height * 4);
|
||||
}
|
||||
const tmpCanvas = tmpCanvasRef.current;
|
||||
|
||||
const rgba = rgbaRef.current;
|
||||
if (tmpCanvas.width !== width || tmpCanvas.height !== height) {
|
||||
tmpCanvas.width = width;
|
||||
tmpCanvas.height = height;
|
||||
tmpCtxRef.current = tmpCanvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
rgba[index] = pixels[index + 2];
|
||||
rgba[index + 1] = pixels[index + 1];
|
||||
rgba[index + 2] = pixels[index];
|
||||
rgba[index + 3] = 255;
|
||||
}
|
||||
const tmpCtx = tmpCtxRef.current;
|
||||
if (!tmpCtx) return;
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
imageData.data.set(rgba);
|
||||
const imageData = tmpCtx.createImageData(width, height);
|
||||
imageData.data.set(rgba);
|
||||
tmpCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
setLastFrameAt(new Date().toLocaleTimeString());
|
||||
}, []);
|
||||
renderScaledFrame();
|
||||
setLastFrameAt(new Date().toLocaleTimeString());
|
||||
},
|
||||
[renderScaledFrame],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
async (input?: Partial<ConnectVncInput>) => {
|
||||
@@ -246,19 +291,15 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
console.log("[VNC] binary ArrayBuffer", event.data.byteLength);
|
||||
drawFrame(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
console.log("[VNC] binary Blob", event.data.size);
|
||||
event.data.arrayBuffer().then(drawFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("[VNC] unknown binary payload", event.data);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
@@ -310,28 +351,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
const containerAspect = rect.width / rect.height;
|
||||
const frameAspect = framebuffer.width / framebuffer.height;
|
||||
|
||||
let renderedWidth: number;
|
||||
let renderedHeight: number;
|
||||
let offsetX: number;
|
||||
let offsetY: number;
|
||||
|
||||
if (containerAspect > frameAspect) {
|
||||
renderedHeight = rect.height;
|
||||
renderedWidth = rect.height * frameAspect;
|
||||
offsetX = rect.left + (rect.width - renderedWidth) / 2;
|
||||
offsetY = rect.top;
|
||||
} else {
|
||||
renderedWidth = rect.width;
|
||||
renderedHeight = rect.width / frameAspect;
|
||||
offsetX = rect.left;
|
||||
offsetY = rect.top + (rect.height - renderedHeight) / 2;
|
||||
}
|
||||
|
||||
const relativeX = (clientX - offsetX) / renderedWidth;
|
||||
const relativeY = (clientY - offsetY) / renderedHeight;
|
||||
const relativeX = (clientX - rect.left) / rect.width;
|
||||
const relativeY = (clientY - rect.top) / rect.height;
|
||||
|
||||
const clampedX = Math.max(0, Math.min(1, relativeX));
|
||||
const clampedY = Math.max(0, Math.min(1, relativeY));
|
||||
@@ -350,6 +371,21 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
[sendClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
renderScaledFrame();
|
||||
});
|
||||
|
||||
observer.observe(canvas);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [renderScaledFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeSocket();
|
||||
|
||||
@@ -30,19 +30,17 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
defaultHost: runtimeConfig.vnc.defaultHost,
|
||||
defaultPort: runtimeConfig.vnc.defaultPort,
|
||||
});
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const connectionLabel = getConnectionLabel(vnc.state);
|
||||
const hasFrame = vnc.frameSize.width > 0 && vnc.frameSize.height > 0;
|
||||
const frameAspectRatio = hasFrame
|
||||
? `${vnc.frameSize.width} / ${vnc.frameSize.height}`
|
||||
: "800 / 480";
|
||||
|
||||
return (
|
||||
<div className={isDark ? "h-full min-h-0 text-slate-100" : "h-full min-h-0 text-slate-950"}>
|
||||
<section className="grid h-full min-h-0 gap-4 xl:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<section className="grid h-full min-h-0 gap-3 lg:grid-cols-[240px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<aside className={panelClass(isDark)}>
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-black uppercase tracking-[0.20em] text-slate-500">
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.20em] text-slate-500">
|
||||
ACESSO REMOTO
|
||||
</p>
|
||||
<h2 className={panelTitleClass(isDark)}>
|
||||
@@ -50,17 +48,15 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<StatusCard
|
||||
theme={theme}
|
||||
icon={<Wifi className="h-4 w-4" />}
|
||||
title="Estado da sessão"
|
||||
value={connectionLabel}
|
||||
color={vnc.connected ? "green" : vnc.connecting ? "blue" : "purple"}
|
||||
/>
|
||||
</div>
|
||||
<StatusCard
|
||||
theme={theme}
|
||||
icon={<Wifi className="h-4 w-4" />}
|
||||
title="Estado da sessão"
|
||||
value={connectionLabel}
|
||||
color={vnc.connected ? "green" : vnc.connecting ? "blue" : "purple"}
|
||||
/>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="mt-3 space-y-3">
|
||||
<Field
|
||||
theme={theme}
|
||||
label="Host"
|
||||
@@ -113,20 +109,20 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
type="button"
|
||||
disabled={vnc.connecting}
|
||||
onClick={() => (vnc.connected ? vnc.disconnect() : vnc.connect())}
|
||||
className="mt-5 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-[5px] bg-[#4FD1C5] text-sm font-black text-[#031014] transition hover:bg-[#5FE1D5] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="mt-3 flex h-9 w-full cursor-pointer items-center justify-center gap-2 rounded-[5px] bg-[#4FD1C5] text-xs font-black text-[#031014] transition hover:bg-[#5FE1D5] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plug className="h-4 w-4" />
|
||||
{vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"}
|
||||
</button>
|
||||
|
||||
{vnc.error && (
|
||||
<div className="mt-4 flex gap-3 rounded-[5px] border border-red-500/25 bg-red-500/10 p-4 text-xs font-semibold leading-5 text-red-200">
|
||||
<div className="mt-3 flex gap-2 rounded-[5px] border border-red-500/25 bg-red-500/10 p-3 text-xs font-semibold leading-5 text-red-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{vnc.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-4 hidden space-y-3 2xl:block">
|
||||
<SmallInfo theme={theme} icon={<ShieldCheck className="h-4 w-4" />} label="Modo" value="LAN direta" />
|
||||
<SmallInfo theme={theme} icon={<Wrench className="h-4 w-4" />} label="Controlador" value={vnc.host} />
|
||||
<SmallInfo theme={theme} icon={<MousePointerClick className="h-4 w-4" />} label="Interação" value="Cliques ativos" />
|
||||
@@ -134,12 +130,12 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
</aside>
|
||||
|
||||
<section className={`${panelClass(isDark)} min-w-0 p-0`}>
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/10 px-5">
|
||||
<div>
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 px-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className={panelTitleClass(isDark)}>
|
||||
Ecrã do controlador
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs font-semibold text-slate-500">
|
||||
<p className="mt-0.5 truncate text-xs font-semibold text-slate-500">
|
||||
{vnc.lastFrameAt ? `Último frame: ${vnc.lastFrameAt}` : "A aguardar imagem da consola"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -147,12 +143,12 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black">
|
||||
{!hasFrame && (
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<div className="grid h-16 w-16 place-items-center rounded-[5px] border border-sky-400/20 bg-sky-400/10">
|
||||
<Monitor className="h-8 w-8 text-[#4FD1C5]" />
|
||||
<div className="flex max-w-md flex-col items-center px-6 text-center">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-[5px] border border-sky-400/20 bg-sky-400/10">
|
||||
<Monitor className="h-7 w-7 text-[#4FD1C5]" />
|
||||
</div>
|
||||
|
||||
<h3 className="mt-5 text-xl font-black tracking-[-0.04em] text-white">
|
||||
<h3 className="mt-4 text-lg font-black tracking-[-0.04em] text-white">
|
||||
{vnc.state === "DISCONNECTED" ? "Sessão terminada" : "Consola indisponível"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">
|
||||
@@ -174,12 +170,7 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
>
|
||||
<canvas
|
||||
ref={vnc.canvasRef}
|
||||
className="pointer-events-none block max-h-full max-w-full bg-black object-contain [image-rendering:pixelated]"
|
||||
style={{
|
||||
aspectRatio: frameAspectRatio,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="pointer-events-none block h-full w-full bg-black [image-rendering:pixelated]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,14 +182,14 @@ export function ConsolePage({ theme }: ConsolePageProps) {
|
||||
|
||||
function panelClass(isDark: boolean) {
|
||||
return isDark
|
||||
? `${RADIUS} flex h-full min-h-0 flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]`
|
||||
: `${RADIUS} flex h-full min-h-0 flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`;
|
||||
? `${RADIUS} flex h-full min-h-0 flex-col border border-white/10 bg-[#071421] p-2 shadow-[0_14px_34px_rgba(0,0,0,0.22)]`
|
||||
: `${RADIUS} flex h-full min-h-0 flex-col border border-slate-200 bg-white p-2 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`;
|
||||
}
|
||||
|
||||
function panelTitleClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "text-base font-black text-slate-100"
|
||||
: "text-base font-black text-slate-950";
|
||||
? "text-sm font-black text-slate-100"
|
||||
: "text-sm font-black text-slate-950";
|
||||
}
|
||||
|
||||
function getConnectionLabel(state: VncConnectionState) {
|
||||
@@ -245,14 +236,14 @@ function Field({
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "text-[11px] font-black uppercase tracking-[0.20em] text-[#7F8CA3]"
|
||||
: "text-[11px] font-black uppercase tracking-[0.20em] text-slate-500"
|
||||
? "text-[10px] font-black uppercase tracking-[0.18em] text-[#7F8CA3]"
|
||||
: "text-[10px] font-black uppercase tracking-[0.18em] text-slate-500"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<div className="relative mt-1.5">
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
@@ -260,8 +251,8 @@ function Field({
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} h-10 w-full border border-[#263247] bg-[#07101B] px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-white outline-none transition placeholder:text-[#526074] focus:border-[#4FD1C5] disabled:cursor-not-allowed disabled:opacity-60`
|
||||
: `${RADIUS} h-10 w-full border border-slate-200 bg-white px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-[#0F172A] outline-none transition placeholder:text-slate-400 focus:border-[#0F766E] disabled:cursor-not-allowed disabled:opacity-60`
|
||||
? `${RADIUS} h-9 w-full border border-[#263247] bg-[#07101B] px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-white outline-none transition placeholder:text-[#526074] focus:border-[#4FD1C5] disabled:cursor-not-allowed disabled:opacity-60`
|
||||
: `${RADIUS} h-9 w-full border border-slate-200 bg-white px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-[#0F172A] outline-none transition placeholder:text-slate-400 focus:border-[#0F766E] disabled:cursor-not-allowed disabled:opacity-60`
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -308,13 +299,13 @@ function StatusCard({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} min-w-0 border border-[#263247] bg-[#111A2B] p-3`
|
||||
: `${RADIUS} min-w-0 border border-[#D7DEE8] bg-[#F8FAFC] p-3`
|
||||
? `${RADIUS} min-w-0 border border-[#263247] bg-[#111A2B] p-2`
|
||||
: `${RADIUS} min-w-0 border border-[#D7DEE8] bg-[#F8FAFC] p-2`
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center ${RADIUS} ${colors[color]}`}
|
||||
className={`grid h-9 w-9 shrink-0 place-items-center ${RADIUS} ${colors[color]}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
@@ -323,8 +314,8 @@ function StatusCard({
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-[#7F8CA3]"
|
||||
: "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500"
|
||||
? "truncate text-[9px] font-bold uppercase tracking-[0.16em] text-[#7F8CA3]"
|
||||
: "truncate text-[9px] font-bold uppercase tracking-[0.16em] text-slate-500"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
@@ -333,8 +324,8 @@ function StatusCard({
|
||||
<h3
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 truncate text-sm font-black text-white"
|
||||
: "mt-1 truncate text-sm font-black text-[#0F172A]"
|
||||
? "mt-0.5 truncate text-sm font-black text-white"
|
||||
: "mt-0.5 truncate text-sm font-black text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
|
||||
Reference in New Issue
Block a user