diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 06c4a242..e255e072 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -166,6 +166,7 @@ const GuacamoleAppInner = React.forwardRef< : null, ); const displayRef = useRef(null); + const [displayZoom, setDisplayZoom] = useState(1); const [filesystem, setFilesystem] = useState(null); const [fileBrowserOpen, setFileBrowserOpen] = useState(false); const [pendingUploads, setPendingUploads] = useState([]); @@ -472,6 +473,7 @@ const GuacamoleAppInner = React.forwardRef< }), }) } + onZoomChange={setDisplayZoom} onFilesystem={setFilesystem} onDropFiles={handleDropFiles} onDropUnavailable={handleDropUnavailable} @@ -494,6 +496,7 @@ const GuacamoleAppInner = React.forwardRef< fileBrowserOpen={fileBrowserOpen} onToggleFileBrowser={() => setFileBrowserOpen((open) => !open)} onTouchModeChange={setTouchMode} + zoom={displayZoom} /> {shareModalOpen && guacamoleConnectionId && ( void; setClipboard: (data: string) => void; getFilesystem: () => Guacamole.Object | null; + zoomIn: () => number; + zoomOut: () => number; + resetZoom: () => number; } export type GuacamoleTouchMode = "touchscreen" | "touchpad"; @@ -72,6 +76,7 @@ interface GuacamoleDisplayProps { onDropFiles?: (files: File[]) => void; onDropUnavailable?: () => void; onStageChange?: (stage: ConnectionStage) => void; + onZoomChange?: (zoom: number) => void; } const isDev = import.meta.env.DEV; @@ -92,6 +97,7 @@ export const GuacamoleDisplay = forwardRef< onDropFiles, onDropUnavailable, onStageChange, + onZoomChange, }, ref, ) { @@ -113,6 +119,8 @@ export const GuacamoleDisplay = forwardRef< onStageChangeRef.current = onStageChange; const keyboardRef = useRef(null); const scaleRef = useRef(1); + const fitScaleRef = useRef(1); + const zoomRef = useRef(1); const resizeTimeoutRef = useRef(null); const hasKeyboardFocusRef = useRef(false); const windowFocusedRef = useRef( @@ -141,6 +149,24 @@ export const GuacamoleDisplay = forwardRef< } }, []); + const applyZoom = useCallback( + (nextZoom: number): number => { + const zoom = clampGuacamoleZoom(nextZoom); + zoomRef.current = zoom; + const display = clientRef.current?.getDisplay(); + if (display && displayRef.current) { + const scale = fitScaleRef.current * zoom; + scaleRef.current = scale; + display.scale(scale); + displayRef.current.style.width = `${display.getWidth() * scale}px`; + displayRef.current.style.height = `${display.getHeight() * scale}px`; + } + onZoomChange?.(zoom); + return zoom; + }, + [onZoomChange], + ); + useImperativeHandle(ref, () => ({ disconnect: disconnectClient, isConnected: () => isReady && !hasError, @@ -171,6 +197,9 @@ export const GuacamoleDisplay = forwardRef< } }, getFilesystem: () => filesystemRef.current, + zoomIn: () => applyZoom(stepGuacamoleZoom(zoomRef.current, 1)), + zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)), + resetZoom: () => applyZoom(1), })); const getWebSocketConnection = useCallback( @@ -315,34 +344,44 @@ export const GuacamoleDisplay = forwardRef< }; }, [isVisible]); - const rescaleDisplay = useCallback((immediate: boolean = false) => { - if (!clientRef.current || !containerRef.current) return; - - const performRescale = () => { + const rescaleDisplay = useCallback( + (immediate: boolean = false) => { if (!clientRef.current || !containerRef.current) return; - const display = clientRef.current.getDisplay(); - const cWidth = containerRef.current.clientWidth; - const cHeight = containerRef.current.clientHeight; - const displayWidth = display.getWidth(); - const displayHeight = display.getHeight(); + const performRescale = () => { + if (!clientRef.current || !containerRef.current) return; - if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) { - const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight); - scaleRef.current = scale; - display.scale(scale); - } - }; + const display = clientRef.current.getDisplay(); + const cWidth = containerRef.current.clientWidth; + const cHeight = containerRef.current.clientHeight; + const displayWidth = display.getWidth(); + const displayHeight = display.getHeight(); - if (immediate) { - performRescale(); - } else { - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); + if ( + displayWidth > 0 && + displayHeight > 0 && + cWidth > 0 && + cHeight > 0 + ) { + fitScaleRef.current = Math.min( + cWidth / displayWidth, + cHeight / displayHeight, + ); + applyZoom(zoomRef.current); + } + }; + + if (immediate) { + performRescale(); + } else { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(performRescale, 200); } - resizeTimeoutRef.current = setTimeout(performRescale, 200); - } - }, []); + }, + [applyZoom], + ); const connect = useCallback(async () => { if (isConnectingRef.current) return; @@ -762,6 +801,51 @@ export const GuacamoleDisplay = forwardRef< } }, [isVisible, isReady, syncClipboard]); + useEffect(() => { + const container = containerRef.current; + const protocol = connectionConfig.protocol ?? connectionConfig.type; + if (!container || !isReady || protocol !== "vnc") return; + + let pinchDistance = 0; + let pinchZoom = zoomRef.current; + const distance = (touches: TouchList) => + Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY, + ); + const onTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 2) return; + event.preventDefault(); + event.stopPropagation(); + pinchDistance = distance(event.touches); + pinchZoom = zoomRef.current; + }; + const onTouchMove = (event: TouchEvent) => { + if (event.touches.length !== 2 || pinchDistance === 0) return; + event.preventDefault(); + event.stopPropagation(); + applyZoom(pinchZoom * (distance(event.touches) / pinchDistance)); + }; + const onTouchEnd = () => { + pinchDistance = 0; + }; + + container.addEventListener("touchstart", onTouchStart, { + passive: false, + capture: true, + }); + container.addEventListener("touchmove", onTouchMove, { + passive: false, + capture: true, + }); + container.addEventListener("touchend", onTouchEnd, { capture: true }); + return () => { + container.removeEventListener("touchstart", onTouchStart, true); + container.removeEventListener("touchmove", onTouchMove, true); + container.removeEventListener("touchend", onTouchEnd, true); + }; + }, [applyZoom, connectionConfig.protocol, connectionConfig.type, isReady]); + useEffect(() => { const container = containerRef.current; if (!container || !isReady) return; @@ -818,7 +902,7 @@ export const GuacamoleDisplay = forwardRef< return (
{ @@ -829,7 +913,7 @@ export const GuacamoleDisplay = forwardRef< >
void; onTouchModeChange?: (mode: GuacamoleTouchMode) => void; + zoom?: number; } const MODIFIER_KEYSYMS = { @@ -73,6 +77,7 @@ function TipBtn({