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 ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const rgbaRef = useRef<Uint8ClampedArray | 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 framebufferRef = useRef({ width: 0, height: 0 });
const [state, setState] = useState<VncConnectionState>("IDLE"); const [state, setState] = useState<VncConnectionState>("IDLE");
@@ -45,12 +48,49 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const [lastFrameAt, setLastFrameAt] = useState<string | null>(null); const [lastFrameAt, setLastFrameAt] = useState<string | null>(null);
const buildWebSocketUrl = useCallback(() => { const buildWebSocketUrl = useCallback(() => {
if (!accessToken) { return accessToken ? appendAccessToken(websocketUrl, accessToken) : websocketUrl;
return 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); if (!ctxRef.current) {
}, [websocketUrl, accessToken]); 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 clearFrame = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -64,6 +104,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
ctxRef.current = null; ctxRef.current = null;
rgbaRef.current = null; rgbaRef.current = null;
tmpCanvasRef.current = null;
tmpCtxRef.current = null;
framebufferRef.current = { width: 0, height: 0 }; framebufferRef.current = { width: 0, height: 0 };
setFrameSize({ width: 0, height: 0 }); setFrameSize({ width: 0, height: 0 });
@@ -96,63 +138,66 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
} }
}, []); }, []);
const drawFrame = useCallback((buffer: ArrayBuffer) => { const drawFrame = useCallback(
const canvas = canvasRef.current; (buffer: ArrayBuffer) => {
if (!canvas) return; 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); if (!width || !height || width <= 0 || height <= 0) return;
const width = view.getInt32(0);
const height = view.getInt32(4);
console.log("[VNC] drawFrame", { const pixels = new Uint8ClampedArray(buffer, 8);
byteLength: buffer.byteLength,
width,
height,
});
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); if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) {
framebufferRef.current = { width, height }; rgbaRef.current = new Uint8ClampedArray(width * height * 4);
}
if (!ctxRef.current) { const rgba = rgbaRef.current;
ctxRef.current = canvas.getContext("2d", {
alpha: false,
desynchronized: true,
});
}
const ctx = ctxRef.current; for (let index = 0; index < pixels.length; index += 4) {
if (!ctx) return; 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) { if (!tmpCanvasRef.current) {
canvas.width = width; tmpCanvasRef.current = document.createElement("canvas");
canvas.height = height; }
rgbaRef.current = new Uint8ClampedArray(width * height * 4);
setFrameSize({ width, height });
}
if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) { const tmpCanvas = tmpCanvasRef.current;
rgbaRef.current = new Uint8ClampedArray(width * height * 4);
}
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) { const tmpCtx = tmpCtxRef.current;
rgba[index] = pixels[index + 2]; if (!tmpCtx) return;
rgba[index + 1] = pixels[index + 1];
rgba[index + 2] = pixels[index];
rgba[index + 3] = 255;
}
const imageData = ctx.createImageData(width, height); const imageData = tmpCtx.createImageData(width, height);
imageData.data.set(rgba); imageData.data.set(rgba);
tmpCtx.putImageData(imageData, 0, 0);
ctx.putImageData(imageData, 0, 0); renderScaledFrame();
setLastFrameAt(new Date().toLocaleTimeString()); setLastFrameAt(new Date().toLocaleTimeString());
}, []); },
[renderScaledFrame],
);
const connect = useCallback( const connect = useCallback(
async (input?: Partial<ConnectVncInput>) => { async (input?: Partial<ConnectVncInput>) => {
@@ -246,19 +291,15 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
return; return;
} }
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
console.log("[VNC] binary ArrayBuffer", event.data.byteLength);
drawFrame(event.data); drawFrame(event.data);
return; return;
} }
if (event.data instanceof Blob) { if (event.data instanceof Blob) {
console.log("[VNC] binary Blob", event.data.size);
event.data.arrayBuffer().then(drawFrame); event.data.arrayBuffer().then(drawFrame);
return;
} }
console.warn("[VNC] unknown binary payload", event.data);
}; };
socket.onerror = () => { socket.onerror = () => {
@@ -310,28 +351,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const containerAspect = rect.width / rect.height; const relativeX = (clientX - rect.left) / rect.width;
const frameAspect = framebuffer.width / framebuffer.height; const relativeY = (clientY - rect.top) / rect.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 clampedX = Math.max(0, Math.min(1, relativeX)); const clampedX = Math.max(0, Math.min(1, relativeX));
const clampedY = Math.max(0, Math.min(1, relativeY)); const clampedY = Math.max(0, Math.min(1, relativeY));
@@ -350,6 +371,21 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
[sendClick], [sendClick],
); );
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new ResizeObserver(() => {
renderScaledFrame();
});
observer.observe(canvas);
return () => {
observer.disconnect();
};
}, [renderScaledFrame]);
useEffect(() => { useEffect(() => {
return () => { return () => {
closeSocket(); closeSocket();
+40 -49
View File
@@ -30,19 +30,17 @@ export function ConsolePage({ theme }: ConsolePageProps) {
defaultHost: runtimeConfig.vnc.defaultHost, defaultHost: runtimeConfig.vnc.defaultHost,
defaultPort: runtimeConfig.vnc.defaultPort, defaultPort: runtimeConfig.vnc.defaultPort,
}); });
const [passwordVisible, setPasswordVisible] = useState(false); const [passwordVisible, setPasswordVisible] = useState(false);
const connectionLabel = getConnectionLabel(vnc.state); const connectionLabel = getConnectionLabel(vnc.state);
const hasFrame = vnc.frameSize.width > 0 && vnc.frameSize.height > 0; const hasFrame = vnc.frameSize.width > 0 && vnc.frameSize.height > 0;
const frameAspectRatio = hasFrame
? `${vnc.frameSize.width} / ${vnc.frameSize.height}`
: "800 / 480";
return ( return (
<div className={isDark ? "h-full min-h-0 text-slate-100" : "h-full min-h-0 text-slate-950"}> <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)}> <aside className={panelClass(isDark)}>
<div className="mb-5"> <div className="mb-3">
<p className="text-xs font-black uppercase tracking-[0.20em] text-slate-500"> <p className="text-[10px] font-black uppercase tracking-[0.20em] text-slate-500">
ACESSO REMOTO ACESSO REMOTO
</p> </p>
<h2 className={panelTitleClass(isDark)}> <h2 className={panelTitleClass(isDark)}>
@@ -50,17 +48,15 @@ export function ConsolePage({ theme }: ConsolePageProps) {
</h2> </h2>
</div> </div>
<div className="grid grid-cols-1 gap-2"> <StatusCard
<StatusCard theme={theme}
theme={theme} icon={<Wifi className="h-4 w-4" />}
icon={<Wifi className="h-4 w-4" />} title="Estado da sessão"
title="Estado da sessão" value={connectionLabel}
value={connectionLabel} color={vnc.connected ? "green" : vnc.connecting ? "blue" : "purple"}
color={vnc.connected ? "green" : vnc.connecting ? "blue" : "purple"} />
/>
</div>
<div className="mt-5 space-y-4"> <div className="mt-3 space-y-3">
<Field <Field
theme={theme} theme={theme}
label="Host" label="Host"
@@ -113,20 +109,20 @@ export function ConsolePage({ theme }: ConsolePageProps) {
type="button" type="button"
disabled={vnc.connecting} disabled={vnc.connecting}
onClick={() => (vnc.connected ? vnc.disconnect() : vnc.connect())} 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" /> <Plug className="h-4 w-4" />
{vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"} {vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"}
</button> </button>
{vnc.error && ( {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" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{vnc.error}</span> <span>{vnc.error}</span>
</div> </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={<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={<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" /> <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> </aside>
<section className={`${panelClass(isDark)} min-w-0 p-0`}> <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"> <header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 px-4">
<div> <div className="min-w-0">
<h2 className={panelTitleClass(isDark)}> <h2 className={panelTitleClass(isDark)}>
Ecrã do controlador Ecrã do controlador
</h2> </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"} {vnc.lastFrameAt ? `Último frame: ${vnc.lastFrameAt}` : "A aguardar imagem da consola"}
</p> </p>
</div> </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"> <div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black">
{!hasFrame && ( {!hasFrame && (
<div className="flex max-w-md flex-col items-center text-center"> <div className="flex max-w-md flex-col items-center px-6 text-center">
<div className="grid h-16 w-16 place-items-center rounded-[5px] border border-sky-400/20 bg-sky-400/10"> <div className="grid h-14 w-14 place-items-center rounded-[5px] border border-sky-400/20 bg-sky-400/10">
<Monitor className="h-8 w-8 text-[#4FD1C5]" /> <Monitor className="h-7 w-7 text-[#4FD1C5]" />
</div> </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"} {vnc.state === "DISCONNECTED" ? "Sessão terminada" : "Consola indisponível"}
</h3> </h3>
<p className="mt-2 text-sm leading-6 text-slate-400"> <p className="mt-2 text-sm leading-6 text-slate-400">
@@ -174,12 +170,7 @@ export function ConsolePage({ theme }: ConsolePageProps) {
> >
<canvas <canvas
ref={vnc.canvasRef} ref={vnc.canvasRef}
className="pointer-events-none block max-h-full max-w-full bg-black object-contain [image-rendering:pixelated]" className="pointer-events-none block h-full w-full bg-black [image-rendering:pixelated]"
style={{
aspectRatio: frameAspectRatio,
width: "100%",
height: "100%",
}}
/> />
</div> </div>
</div> </div>
@@ -191,14 +182,14 @@ export function ConsolePage({ theme }: ConsolePageProps) {
function panelClass(isDark: boolean) { function panelClass(isDark: boolean) {
return isDark 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-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-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`; : `${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) { function panelTitleClass(isDark: boolean) {
return isDark return isDark
? "text-base font-black text-slate-100" ? "text-sm font-black text-slate-100"
: "text-base font-black text-slate-950"; : "text-sm font-black text-slate-950";
} }
function getConnectionLabel(state: VncConnectionState) { function getConnectionLabel(state: VncConnectionState) {
@@ -245,14 +236,14 @@ function Field({
<span <span
className={ className={
isDark isDark
? "text-[11px] font-black uppercase tracking-[0.20em] text-[#7F8CA3]" ? "text-[10px] font-black uppercase tracking-[0.18em] text-[#7F8CA3]"
: "text-[11px] font-black uppercase tracking-[0.20em] text-slate-500" : "text-[10px] font-black uppercase tracking-[0.18em] text-slate-500"
} }
> >
{label} {label}
</span> </span>
<div className="relative mt-2"> <div className="relative mt-1.5">
<input <input
type={type} type={type}
value={value} value={value}
@@ -260,8 +251,8 @@ function Field({
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
className={ className={
isDark 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-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-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-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 <div
className={ className={
isDark isDark
? `${RADIUS} min-w-0 border border-[#263247] bg-[#111A2B] p-3` ? `${RADIUS} min-w-0 border border-[#263247] bg-[#111A2B] p-2`
: `${RADIUS} min-w-0 border border-[#D7DEE8] bg-[#F8FAFC] p-3` : `${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 <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} {icon}
</div> </div>
@@ -323,8 +314,8 @@ function StatusCard({
<p <p
className={ className={
isDark isDark
? "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-[#7F8CA3]" ? "truncate text-[9px] font-bold uppercase tracking-[0.16em] text-[#7F8CA3]"
: "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500" : "truncate text-[9px] font-bold uppercase tracking-[0.16em] text-slate-500"
} }
> >
{title} {title}
@@ -333,8 +324,8 @@ function StatusCard({
<h3 <h3
className={ className={
isDark isDark
? "mt-1 truncate text-sm font-black text-white" ? "mt-0.5 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-[#0F172A]"
} }
> >
{value} {value}