@redocly/theme
Version:
Shared UI components lib
312 lines (309 loc) • 13.6 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SvgViewer = SvgViewer;
const react_1 = __importStar(require("react"));
const styled_components_1 = __importStar(require("styled-components"));
const hooks_1 = require("../../core/hooks");
const Button_1 = require("../../components/Button/Button");
const Tooltip_1 = require("../../components/Tooltip/Tooltip");
const AddIcon_1 = require("../../icons/AddIcon/AddIcon");
const SubtractIcon_1 = require("../../icons/SubtractIcon/SubtractIcon");
const CloseIcon_1 = require("../../icons/CloseIcon/CloseIcon");
const FitToViewIcon_1 = require("../../icons/FitToViewIcon/FitToViewIcon");
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;
function SvgViewer({ isOpen, onClose, children, labels = {}, }) {
const [scale, setScale] = (0, react_1.useState)(1);
const [baseScale, setBaseScale] = (0, react_1.useState)(1);
const [position, setPosition] = (0, react_1.useState)({ x: 0, y: 0 });
const [isDragging, setIsDragging] = (0, react_1.useState)(false);
const [dragStart, setDragStart] = (0, react_1.useState)({ x: 0, y: 0 });
const [pinchState, setPinchState] = (0, react_1.useState)(null);
const [isWheelZooming, setIsWheelZooming] = (0, react_1.useState)(false);
const wheelTimeoutRef = (0, react_1.useRef)(null);
const overlayRef = (0, react_1.useRef)(null);
const viewportRef = (0, react_1.useRef)(null);
const contentRef = (0, react_1.useRef)(null);
const renderedScaleRef = (0, react_1.useRef)(scale);
(0, hooks_1.useModalScrollLock)(isOpen);
// Keep track of the actually rendered scale for accurate measurements
(0, react_1.useLayoutEffect)(() => {
renderedScaleRef.current = scale;
}, [scale]);
const minScale = baseScale * MIN_SCALE_FACTOR;
const maxScale = baseScale * MAX_SCALE_FACTOR;
const clampScale = (0, react_1.useCallback)((value) => Math.min(maxScale, Math.max(minScale, value)), [minScale, maxScale]);
const calculateFitScale = (0, react_1.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 = (0, react_1.useCallback)(() => {
setScale(baseScale);
setPosition({ x: 0, y: 0 });
}, [baseScale]);
const zoomIn = (0, react_1.useCallback)(() => {
setScale((s) => clampScale(s + baseScale * ZOOM_STEP));
}, [baseScale, clampScale]);
const zoomOut = (0, react_1.useCallback)(() => {
setScale((s) => clampScale(s - baseScale * ZOOM_STEP));
}, [baseScale, clampScale]);
const handleKeyDown = (0, react_1.useCallback)((e) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
zoomOut();
break;
case '0':
resetView();
break;
}
}, [onClose, zoomIn, zoomOut, resetView]);
const handleWheel = (0, react_1.useCallback)((e) => {
e.preventDefault();
e.stopPropagation();
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 = (0, react_1.useCallback)((e) => {
if (e.button !== 0)
return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
}, [position]);
const handleMouseMove = (0, react_1.useCallback)((e) => {
if (!isDragging)
return;
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
}, [isDragging, dragStart]);
const handleMouseUp = (0, react_1.useCallback)(() => setIsDragging(false), []);
const getTouchDistance = (touches) => {
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 = (0, react_1.useCallback)((e) => {
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 = (0, react_1.useCallback)((e) => {
e.preventDefault();
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 = (0, react_1.useCallback)(() => {
setIsDragging(false);
setPinchState(null);
}, []);
(0, react_1.useEffect)(() => {
var _a;
if (!isOpen)
return;
setPosition({ x: 0, y: 0 });
(_a = overlayRef.current) === null || _a === void 0 ? void 0 : _a.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 (react_1.default.createElement(Overlay, { ref: overlayRef, onClick: onClose, onKeyDown: handleKeyDown, tabIndex: 0, "aria-modal": "true", role: "dialog", "aria-label": labels.dialogLabel || 'SVG viewer' },
react_1.default.createElement(Viewport, { ref: viewportRef, onClick: (e) => e.stopPropagation(), onWheel: handleWheel, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, "$isDragging": isDragging },
react_1.default.createElement(Content, { ref: contentRef, "$isAnimating": isAnimating, style: {
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${scale})`,
} }, children),
react_1.default.createElement(Controls, null,
react_1.default.createElement(ControlGroup, null,
react_1.default.createElement(Tooltip_1.Tooltip, { tip: labels.zoomOut || 'Zoom out', placement: "top" },
react_1.default.createElement(ControlButton, { variant: "text", size: "small", icon: react_1.default.createElement(SubtractIcon_1.SubtractIcon, null), onClick: zoomOut, disabled: scale <= minScale })),
react_1.default.createElement(ZoomLabel, null,
zoomPercentage,
"%"),
react_1.default.createElement(Tooltip_1.Tooltip, { tip: labels.zoomIn || 'Zoom in', placement: "top" },
react_1.default.createElement(ControlButton, { variant: "text", size: "small", icon: react_1.default.createElement(AddIcon_1.AddIcon, null), onClick: zoomIn, disabled: scale >= maxScale })),
react_1.default.createElement(Divider, null),
react_1.default.createElement(Tooltip_1.Tooltip, { tip: labels.fitToView || 'Fit to view', placement: "top" },
react_1.default.createElement(ControlButton, { variant: "text", size: "small", icon: react_1.default.createElement(FitToViewIcon_1.FitToViewIcon, null), onClick: resetView })),
react_1.default.createElement(Tooltip_1.Tooltip, { tip: labels.close || 'Close', placement: "top" },
react_1.default.createElement(ControlButton, { variant: "text", size: "small", icon: react_1.default.createElement(CloseIcon_1.CloseIcon, null), onClick: onClose })))))));
}
const scaleIn = (0, styled_components_1.keyframes) `
from {
transform: scale(0.9);
}
to {
transform: scale(1);
}
`;
const slideUp = (0, styled_components_1.keyframes) `
from {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`;
const Overlay = styled_components_1.default.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_components_1.default.div `
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_components_1.default.div `
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_components_1.default.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_components_1.default.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 = (0, styled_components_1.default)(Button_1.Button) `
--button-icon-size: 16px;
`;
const ZoomLabel = styled_components_1.default.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_components_1.default.div `
width: 1px;
height: var(--spacing-base);
background: var(--border-color-primary);
margin: 0 var(--spacing-xxs);
`;
//# sourceMappingURL=SvgViewer.js.map