import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import styled, { keyframes } from 'styled-components'; import type { JSX, TouchEvent as ReactTouchEvent, WheelEvent, MouseEvent, ReactNode } from 'react'; import { useModalScrollLock } from '@redocly/theme/core/hooks'; import { Button } from '@redocly/theme/components/Button/Button'; import { Tooltip } from '@redocly/theme/components/Tooltip/Tooltip'; import { AddIcon } from '@redocly/theme/icons/AddIcon/AddIcon'; import { SubtractIcon } from '@redocly/theme/icons/SubtractIcon/SubtractIcon'; import { CloseIcon } from '@redocly/theme/icons/CloseIcon/CloseIcon'; import { FitToViewIcon } from '@redocly/theme/icons/FitToViewIcon/FitToViewIcon'; export type SvgViewerLabels = { zoomIn?: string; zoomOut?: string; fitToView?: string; close?: string; dialogLabel?: string; }; export type SvgViewerProps = { isOpen: boolean; onClose: () => void; children: ReactNode; labels?: SvgViewerLabels; }; type Position = { x: number; y: number }; const MIN_SCALE_FACTOR = 0.1; const MAX_SCALE_FACTOR = 5; const ZOOM_STEP = 0.1; const WHEEL_SENSITIVITY = 0.002; const VIEWPORT_PADDING = 60; const FIT_SCALE_FACTOR = 0.9; export function SvgViewer({ isOpen, onClose, children, labels = {}, }: SvgViewerProps): JSX.Element | null { const [scale, setScale] = useState(1); const [baseScale, setBaseScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [pinchState, setPinchState] = useState<{ distance: number; scale: number } | null>(null); const [isWheelZooming, setIsWheelZooming] = useState(false); const wheelTimeoutRef = useRef | null>(null); const overlayRef = useRef(null); const viewportRef = useRef(null); const contentRef = useRef(null); const renderedScaleRef = useRef(scale); useModalScrollLock(isOpen); // Keep track of the actually rendered scale for accurate measurements useLayoutEffect(() => { renderedScaleRef.current = scale; }, [scale]); const minScale = baseScale * MIN_SCALE_FACTOR; const maxScale = baseScale * MAX_SCALE_FACTOR; const clampScale = useCallback( (value: number) => Math.min(maxScale, Math.max(minScale, value)), [minScale, maxScale], ); const calculateFitScale = useCallback(() => { if (!viewportRef.current || !contentRef.current) return 1; const viewport = viewportRef.current.getBoundingClientRect(); const svg = contentRef.current.querySelector('svg'); if (!svg) return 1; const svgRect = svg.getBoundingClientRect(); if (!svgRect.width || !svgRect.height) return 1; // getBoundingClientRect returns transformed size, so compensate for current scale const currentScale = renderedScaleRef.current || 1; const naturalWidth = svgRect.width / currentScale; const naturalHeight = svgRect.height / currentScale; const availableWidth = viewport.width - VIEWPORT_PADDING * 2; const availableHeight = viewport.height - VIEWPORT_PADDING * 2; return ( Math.min(availableWidth / naturalWidth, availableHeight / naturalHeight) * FIT_SCALE_FACTOR ); }, []); const resetView = useCallback(() => { setScale(baseScale); setPosition({ x: 0, y: 0 }); }, [baseScale]); const zoomIn = useCallback(() => { setScale((s) => clampScale(s + baseScale * ZOOM_STEP)); }, [baseScale, clampScale]); const zoomOut = useCallback(() => { setScale((s) => clampScale(s - baseScale * ZOOM_STEP)); }, [baseScale, clampScale]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'Escape': onClose(); break; case '+': case '=': zoomIn(); break; case '-': zoomOut(); break; case '0': resetView(); break; } }, [onClose, zoomIn, zoomOut, resetView], ); const handleWheel = useCallback( (e: WheelEvent) => { setIsWheelZooming(true); if (wheelTimeoutRef.current) clearTimeout(wheelTimeoutRef.current); wheelTimeoutRef.current = setTimeout(() => setIsWheelZooming(false), 150); const delta = -e.deltaY * WHEEL_SENSITIVITY; setScale((s) => clampScale(s + s * delta)); }, [clampScale], ); const handleMouseDown = useCallback( (e: MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); setIsDragging(true); setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); }, [position], ); const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isDragging) return; setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); }, [isDragging, dragStart], ); const handleMouseUp = useCallback(() => setIsDragging(false), []); const getTouchDistance = (touches: React.TouchList): number => { if (touches.length !== 2) return 0; const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.hypot(dx, dy); }; const handleTouchStart = useCallback( (e: ReactTouchEvent) => { if (e.touches.length === 2) { setPinchState({ distance: getTouchDistance(e.touches), scale }); } else if (e.touches.length === 1) { setIsDragging(true); setDragStart({ x: e.touches[0].clientX - position.x, y: e.touches[0].clientY - position.y, }); } }, [position, scale], ); const handleTouchMove = useCallback( (e: ReactTouchEvent) => { if (e.touches.length === 2 && pinchState) { const distance = getTouchDistance(e.touches); setScale(clampScale(pinchState.scale * (distance / pinchState.distance))); } else if (e.touches.length === 1 && isDragging) { setPosition({ x: e.touches[0].clientX - dragStart.x, y: e.touches[0].clientY - dragStart.y, }); } }, [pinchState, isDragging, dragStart, clampScale], ); const handleTouchEnd = useCallback(() => { setIsDragging(false); setPinchState(null); }, []); useEffect(() => { if (!isOpen) return; setPosition({ x: 0, y: 0 }); overlayRef.current?.focus(); // Wait for DOM to be ready before measuring requestAnimationFrame(() => { const fitScale = calculateFitScale(); setBaseScale(fitScale); setScale(fitScale); }); }, [isOpen, calculateFitScale]); if (!isOpen) return null; const zoomPercentage = baseScale > 0 ? Math.round((scale / baseScale) * 100) : 100; const isAnimating = !isDragging && !isWheelZooming && !pinchState; return ( e.stopPropagation()} onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} $isDragging={isDragging} > {children} } onClick={zoomOut} disabled={scale <= minScale} /> {zoomPercentage}% } onClick={zoomIn} disabled={scale >= maxScale} /> } onClick={resetView} /> } onClick={onClose} /> ); } const scaleIn = keyframes` from { transform: scale(0.9); } to { transform: scale(1); } `; const slideUp = keyframes` from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } `; const Overlay = styled.div` position: fixed; inset: 0; background-color: var(--svg-viewer-overlay-bg-color); backdrop-filter: blur(var(--spacing-unit)); z-index: var(--z-index-overlay, 1000); display: flex; align-items: center; justify-content: center; padding: var(--spacing-xxl); &:focus { outline: none; } @media (max-width: 768px) { padding: var(--spacing-md); } `; const Viewport = styled.div<{ $isDragging: boolean }>` position: relative; width: 100%; height: 100%; background-color: var(--svg-viewer-bg-color); border-radius: var(--svg-viewer-border-radius); overflow: hidden; cursor: ${({ $isDragging }) => ($isDragging ? 'grabbing' : 'grab')}; touch-action: none; box-shadow: var(--svg-viewer-box-shadow); animation: ${scaleIn} 0.25s ease-in-out forwards; `; const Content = styled.div<{ $isAnimating: boolean }>` position: absolute; top: 50%; left: 50%; transform-origin: center center; user-select: none; pointer-events: none; transition: ${({ $isAnimating }) => ($isAnimating ? 'transform 0.25s ease-in-out' : 'none')}; svg { display: block; max-width: none !important; } `; const Controls = styled.div` position: absolute; bottom: var(--spacing-sm); left: 50%; transform: translateX(-50%); z-index: 10; animation: ${slideUp} 0.3s ease-out 0.1s backwards; `; const ControlGroup = styled.div` display: flex; align-items: center; gap: 2px; padding: var(--spacing-xxs); background: var(--bg-color-raised); border: 1px solid var(--border-color-primary); border-radius: var(--border-radius-lg); box-shadow: var(--bg-raised-shadow); `; const ControlButton = styled(Button)` --button-icon-size: 16px; `; const ZoomLabel = styled.span` min-width: 40px; font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--text-color-secondary); text-align: center; font-variant-numeric: tabular-nums; `; const Divider = styled.div` width: 1px; height: var(--spacing-base); background: var(--border-color-primary); margin: 0 var(--spacing-xxs); `;