Fixes console page responsiveness, allowing proper stretch on different viewports

This commit is contained in:
litoral05
2026-06-09 15:45:46 +01:00
parent de3ee6470f
commit 8f3c9d6da8
2 changed files with 153 additions and 126 deletions
+111 -75
View File
@@ -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();
+40 -49
View File
@@ -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}