@redocly/theme 0.62.0-next.3 → 0.62.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/components/Search/SearchDialog.js +11 -2
  2. package/lib/components/SvgViewer/SvgViewer.d.ts +15 -0
  3. package/lib/components/SvgViewer/SvgViewer.js +312 -0
  4. package/lib/components/SvgViewer/variables.d.ts +1 -0
  5. package/lib/components/SvgViewer/variables.dark.d.ts +1 -0
  6. package/lib/components/SvgViewer/variables.dark.js +8 -0
  7. package/lib/components/SvgViewer/variables.js +17 -0
  8. package/lib/components/Tag/variables.dark.js +6 -0
  9. package/lib/components/Tag/variables.js +6 -0
  10. package/lib/core/styles/dark.js +2 -0
  11. package/lib/core/styles/global.js +2 -0
  12. package/lib/core/types/catalog.d.ts +1 -1
  13. package/lib/core/types/l10n.d.ts +1 -1
  14. package/lib/core/utils/transform-revisions-to-version-history.js +8 -51
  15. package/lib/icons/FitToViewIcon/FitToViewIcon.d.ts +9 -0
  16. package/lib/icons/FitToViewIcon/FitToViewIcon.js +25 -0
  17. package/lib/index.d.ts +2 -0
  18. package/lib/index.js +2 -0
  19. package/lib/markdoc/components/Mermaid/Mermaid.js +70 -2
  20. package/package.json +4 -4
  21. package/src/components/Search/SearchDialog.tsx +29 -14
  22. package/src/components/SvgViewer/SvgViewer.tsx +405 -0
  23. package/src/components/SvgViewer/variables.dark.ts +5 -0
  24. package/src/components/SvgViewer/variables.ts +14 -0
  25. package/src/components/Tag/variables.dark.ts +6 -0
  26. package/src/components/Tag/variables.ts +6 -0
  27. package/src/core/styles/dark.ts +2 -0
  28. package/src/core/styles/global.ts +2 -0
  29. package/src/core/types/catalog.ts +1 -1
  30. package/src/core/types/l10n.ts +8 -1
  31. package/src/core/utils/transform-revisions-to-version-history.ts +8 -80
  32. package/src/icons/FitToViewIcon/FitToViewIcon.tsx +26 -0
  33. package/src/index.ts +2 -0
  34. package/src/markdoc/components/Mermaid/Mermaid.tsx +57 -8
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import type { IconProps } from '../../icons/types';
3
+ export declare const FitToViewIcon: import("styled-components").StyledComponent<(props: IconProps) => React.JSX.Element, any, {
4
+ 'data-component-name': string;
5
+ } & {
6
+ color?: string;
7
+ size?: string;
8
+ className?: string;
9
+ } & React.SVGProps<SVGSVGElement>, "data-component-name">;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.FitToViewIcon = void 0;
7
+ const react_1 = __importDefault(require("react"));
8
+ const styled_components_1 = __importDefault(require("styled-components"));
9
+ const utils_1 = require("../../core/utils");
10
+ const Icon = (props) => (react_1.default.createElement("svg", Object.assign({ viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg" }, props),
11
+ react_1.default.createElement("path", { d: "M1 1H5V2.5H2.5V5H1V1Z" }),
12
+ react_1.default.createElement("path", { d: "M15 1H11V2.5H13.5V5H15V1Z" }),
13
+ react_1.default.createElement("path", { d: "M1 15H5V13.5H2.5V11H1V15Z" }),
14
+ react_1.default.createElement("path", { d: "M15 15H11V13.5H13.5V11H15V15Z" })));
15
+ exports.FitToViewIcon = (0, styled_components_1.default)(Icon).attrs(() => ({
16
+ 'data-component-name': 'icons/FitToViewIcon/FitToViewIcon',
17
+ })) `
18
+ path {
19
+ fill: ${({ color }) => (0, utils_1.getCssColorVariable)(color)};
20
+ }
21
+
22
+ height: ${({ size }) => size || '16px'};
23
+ width: ${({ size }) => size || '16px'};
24
+ `;
25
+ //# sourceMappingURL=FitToViewIcon.js.map
package/lib/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from './components/Tags/CounterTag';
25
25
  export * from './components/VersionPicker/VersionPicker';
26
26
  export * from './components/Marker/Marker';
27
27
  export * from './components/PageActions/PageActions';
28
+ export * from './components/SvgViewer/SvgViewer';
28
29
  export * from './components/Buttons/CopyButton';
29
30
  export * from './components/Buttons/EditPageButton';
30
31
  export * from './components/Buttons/EmailButton';
@@ -266,6 +267,7 @@ export * from './icons/WorkflowHierarchyIcon/WorkflowHierarchyIcon';
266
267
  export * from './icons/GenericIcon/GenericIcon';
267
268
  export * from './icons/ShareIcon/ShareIcon';
268
269
  export * from './icons/HashtagIcon/HashtagIcon';
270
+ export * from './icons/FitToViewIcon/FitToViewIcon';
269
271
  export * from './layouts/RootLayout';
270
272
  export * from './layouts/PageLayout';
271
273
  export * from './layouts/NotFound';
package/lib/index.js CHANGED
@@ -64,6 +64,7 @@ __exportStar(require("./components/Tags/CounterTag"), exports);
64
64
  __exportStar(require("./components/VersionPicker/VersionPicker"), exports);
65
65
  __exportStar(require("./components/Marker/Marker"), exports);
66
66
  __exportStar(require("./components/PageActions/PageActions"), exports);
67
+ __exportStar(require("./components/SvgViewer/SvgViewer"), exports);
67
68
  /* Buttons */
68
69
  __exportStar(require("./components/Buttons/CopyButton"), exports);
69
70
  __exportStar(require("./components/Buttons/EditPageButton"), exports);
@@ -329,6 +330,7 @@ __exportStar(require("./icons/WorkflowHierarchyIcon/WorkflowHierarchyIcon"), exp
329
330
  __exportStar(require("./icons/GenericIcon/GenericIcon"), exports);
330
331
  __exportStar(require("./icons/ShareIcon/ShareIcon"), exports);
331
332
  __exportStar(require("./icons/HashtagIcon/HashtagIcon"), exports);
333
+ __exportStar(require("./icons/FitToViewIcon/FitToViewIcon"), exports);
332
334
  /* Layouts */
333
335
  __exportStar(require("./layouts/RootLayout"), exports);
334
336
  __exportStar(require("./layouts/PageLayout"), exports);
@@ -1,18 +1,75 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.Mermaid = Mermaid;
7
- const react_1 = __importDefault(require("react"));
40
+ const react_1 = __importStar(require("react"));
8
41
  const styled_components_1 = __importDefault(require("styled-components"));
9
42
  const utils_1 = require("../../../core/utils");
43
+ const hooks_1 = require("../../../core/hooks");
44
+ const SvgViewer_1 = require("../../../components/SvgViewer/SvgViewer");
10
45
  function Mermaid({ diagramHtml, 'data-source': dataSource, 'data-hash': dataHash, className, }) {
11
- return (react_1.default.createElement(Wrapper, { className: (0, utils_1.concatClassNames)('mermaid-wrapper', className), dangerouslySetInnerHTML: { __html: diagramHtml }, "data-component-name": "Markdoc/Mermaid/Mermaid", "data-source": dataSource, "data-hash": dataHash }));
46
+ const { useTranslate } = (0, hooks_1.useThemeHooks)();
47
+ const { translate } = useTranslate();
48
+ const [isOpen, setIsOpen] = (0, react_1.useState)(false);
49
+ const open = () => setIsOpen(true);
50
+ const close = () => setIsOpen(false);
51
+ return (react_1.default.createElement(react_1.default.Fragment, null,
52
+ react_1.default.createElement(Wrapper, { className: (0, utils_1.concatClassNames)('mermaid-wrapper', className), dangerouslySetInnerHTML: { __html: diagramHtml }, "data-component-name": "Markdoc/Mermaid/Mermaid", "data-source": dataSource, "data-hash": dataHash, onClick: open, onKeyDown: (e) => e.key === 'Enter' || (e.key === ' ' && open()), role: "button", tabIndex: 0, "aria-label": translate('mermaid.openFullscreen', 'Click to open diagram in fullscreen') }),
53
+ react_1.default.createElement(SvgViewer_1.SvgViewer, { isOpen: isOpen, onClose: close, labels: {
54
+ zoomIn: translate('mermaid.zoomIn', 'Zoom in'),
55
+ zoomOut: translate('mermaid.zoomOut', 'Zoom out'),
56
+ fitToView: translate('mermaid.reset', 'Fit to view'),
57
+ close: translate('mermaid.close', 'Close'),
58
+ dialogLabel: translate('mermaid.viewer', 'Mermaid diagram viewer'),
59
+ } },
60
+ react_1.default.createElement(ViewerContent, { dangerouslySetInnerHTML: { __html: diagramHtml } }))));
12
61
  }
13
62
  const Wrapper = styled_components_1.default.div `
14
63
  background-color: var(--mermaid-bg-color);
15
64
  border-radius: var(--mermaid-border-radius);
65
+ cursor: pointer;
66
+ transition: box-shadow 0.2s ease;
67
+
68
+ &:hover,
69
+ &:focus {
70
+ outline: none;
71
+ box-shadow: 0 0 0 2px var(--border-color-input-focus);
72
+ }
16
73
 
17
74
  * {
18
75
  font-family: var(--mermaid-font-family) !important;
@@ -23,4 +80,15 @@ const Wrapper = styled_components_1.default.div `
23
80
  max-width: 100%;
24
81
  }
25
82
  `;
83
+ const ViewerContent = styled_components_1.default.div `
84
+ * {
85
+ font-family: var(--mermaid-font-family) !important;
86
+ }
87
+
88
+ .mermaid > svg {
89
+ font-size: var(--font-size-base) !important;
90
+ display: block;
91
+ max-width: none !important;
92
+ }
93
+ `;
26
94
  //# sourceMappingURL=Mermaid.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/theme",
3
- "version": "0.62.0-next.3",
3
+ "version": "0.62.0-next.5",
4
4
  "description": "Shared UI components lib",
5
5
  "keywords": [
6
6
  "theme",
@@ -29,8 +29,8 @@
29
29
  "@markdoc/markdoc": "0.5.2",
30
30
  "lodash.debounce": "^4.0.8",
31
31
  "lodash.throttle": "^4.1.1",
32
- "react": "19.2.3",
33
- "react-dom": "19.2.3",
32
+ "react": "19.2.4",
33
+ "react-dom": "19.2.4",
34
34
  "react-router-dom": "^6.21.1",
35
35
  "styled-components": "^4.1.1 || ^5.3.11 || ^6.0.0"
36
36
  },
@@ -81,7 +81,7 @@
81
81
  "openapi-sampler": "1.6.2",
82
82
  "react-calendar": "5.1.0",
83
83
  "react-date-picker": "11.0.0",
84
- "@redocly/config": "0.41.2"
84
+ "@redocly/config": "0.41.4"
85
85
  },
86
86
  "scripts": {
87
87
  "watch": "tsc -p tsconfig.build.json && (concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\")",
@@ -262,20 +262,27 @@ export function SearchDialog({
262
262
  </>
263
263
  ) : (
264
264
  <AiDialogHeaderWrapper>
265
- <Button
266
- variant="secondary"
267
- onClick={() => {
268
- setMode('search');
269
- aiSearch.clearConversation();
270
- focusSearchInput();
271
- }}
272
- tabIndex={0}
273
- icon={<ChevronLeftIcon />}
274
- >
275
- {isMobile
276
- ? translate('search.ai.back', 'Back')
277
- : translate('search.ai.backToSearch', 'Back to search')}
278
- </Button>
265
+ {initialMode === 'ai-dialog' ? (
266
+ <AiDialogHeaderTitle>
267
+ <AiStarsGradientIcon color="var(--search-ai-button-icon-color)" size="1.25rem" />
268
+ {translate('search.ai.assistant', 'Assistant')}
269
+ </AiDialogHeaderTitle>
270
+ ) : (
271
+ <Button
272
+ variant="secondary"
273
+ onClick={() => {
274
+ setMode('search');
275
+ aiSearch.clearConversation();
276
+ focusSearchInput();
277
+ }}
278
+ tabIndex={0}
279
+ icon={<ChevronLeftIcon />}
280
+ >
281
+ {isMobile
282
+ ? translate('search.ai.back', 'Back')
283
+ : translate('search.ai.backToSearch', 'Back to search')}
284
+ </Button>
285
+ )}
279
286
  <AiDialogHeaderActionsWrapper>
280
287
  <Button
281
288
  variant="secondary"
@@ -567,6 +574,14 @@ const AiDialogHeaderActionsWrapper = styled.div`
567
574
  gap: var(--spacing-xxs);
568
575
  `;
569
576
 
577
+ const AiDialogHeaderTitle = styled.span`
578
+ display: flex;
579
+ align-items: center;
580
+ gap: var(--spacing-xs);
581
+ font-weight: var(--font-weight-semibold);
582
+ font-size: var(--font-size-lg);
583
+ `;
584
+
570
585
  const SearchDialogBody = styled.div`
571
586
  display: flex;
572
587
  flex-direction: row-reverse;
@@ -0,0 +1,405 @@
1
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import styled, { keyframes } from 'styled-components';
3
+
4
+ import type { JSX, TouchEvent as ReactTouchEvent, WheelEvent, MouseEvent, ReactNode } from 'react';
5
+
6
+ import { useModalScrollLock } from '@redocly/theme/core/hooks';
7
+ import { Button } from '@redocly/theme/components/Button/Button';
8
+ import { Tooltip } from '@redocly/theme/components/Tooltip/Tooltip';
9
+ import { AddIcon } from '@redocly/theme/icons/AddIcon/AddIcon';
10
+ import { SubtractIcon } from '@redocly/theme/icons/SubtractIcon/SubtractIcon';
11
+ import { CloseIcon } from '@redocly/theme/icons/CloseIcon/CloseIcon';
12
+ import { FitToViewIcon } from '@redocly/theme/icons/FitToViewIcon/FitToViewIcon';
13
+
14
+ export type SvgViewerLabels = {
15
+ zoomIn?: string;
16
+ zoomOut?: string;
17
+ fitToView?: string;
18
+ close?: string;
19
+ dialogLabel?: string;
20
+ };
21
+
22
+ export type SvgViewerProps = {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ children: ReactNode;
26
+ labels?: SvgViewerLabels;
27
+ };
28
+
29
+ type Position = { x: number; y: number };
30
+
31
+ const MIN_SCALE_FACTOR = 0.1;
32
+ const MAX_SCALE_FACTOR = 5;
33
+ const ZOOM_STEP = 0.1;
34
+ const WHEEL_SENSITIVITY = 0.002;
35
+ const VIEWPORT_PADDING = 60;
36
+ const FIT_SCALE_FACTOR = 0.9;
37
+
38
+ export function SvgViewer({
39
+ isOpen,
40
+ onClose,
41
+ children,
42
+ labels = {},
43
+ }: SvgViewerProps): JSX.Element | null {
44
+ const [scale, setScale] = useState(1);
45
+ const [baseScale, setBaseScale] = useState(1);
46
+ const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
47
+ const [isDragging, setIsDragging] = useState(false);
48
+ const [dragStart, setDragStart] = useState<Position>({ x: 0, y: 0 });
49
+ const [pinchState, setPinchState] = useState<{ distance: number; scale: number } | null>(null);
50
+ const [isWheelZooming, setIsWheelZooming] = useState(false);
51
+
52
+ const wheelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+ const overlayRef = useRef<HTMLDivElement>(null);
54
+ const viewportRef = useRef<HTMLDivElement>(null);
55
+ const contentRef = useRef<HTMLDivElement>(null);
56
+ const renderedScaleRef = useRef(scale);
57
+
58
+ useModalScrollLock(isOpen);
59
+
60
+ // Keep track of the actually rendered scale for accurate measurements
61
+ useLayoutEffect(() => {
62
+ renderedScaleRef.current = scale;
63
+ }, [scale]);
64
+
65
+ const minScale = baseScale * MIN_SCALE_FACTOR;
66
+ const maxScale = baseScale * MAX_SCALE_FACTOR;
67
+
68
+ const clampScale = useCallback(
69
+ (value: number) => Math.min(maxScale, Math.max(minScale, value)),
70
+ [minScale, maxScale],
71
+ );
72
+
73
+ const calculateFitScale = useCallback(() => {
74
+ if (!viewportRef.current || !contentRef.current) return 1;
75
+
76
+ const viewport = viewportRef.current.getBoundingClientRect();
77
+ const svg = contentRef.current.querySelector('svg');
78
+ if (!svg) return 1;
79
+
80
+ const svgRect = svg.getBoundingClientRect();
81
+ if (!svgRect.width || !svgRect.height) return 1;
82
+
83
+ // getBoundingClientRect returns transformed size, so compensate for current scale
84
+ const currentScale = renderedScaleRef.current || 1;
85
+ const naturalWidth = svgRect.width / currentScale;
86
+ const naturalHeight = svgRect.height / currentScale;
87
+
88
+ const availableWidth = viewport.width - VIEWPORT_PADDING * 2;
89
+ const availableHeight = viewport.height - VIEWPORT_PADDING * 2;
90
+
91
+ return (
92
+ Math.min(availableWidth / naturalWidth, availableHeight / naturalHeight) * FIT_SCALE_FACTOR
93
+ );
94
+ }, []);
95
+
96
+ const resetView = useCallback(() => {
97
+ setScale(baseScale);
98
+ setPosition({ x: 0, y: 0 });
99
+ }, [baseScale]);
100
+
101
+ const zoomIn = useCallback(() => {
102
+ setScale((s) => clampScale(s + baseScale * ZOOM_STEP));
103
+ }, [baseScale, clampScale]);
104
+
105
+ const zoomOut = useCallback(() => {
106
+ setScale((s) => clampScale(s - baseScale * ZOOM_STEP));
107
+ }, [baseScale, clampScale]);
108
+
109
+ const handleKeyDown = useCallback(
110
+ (e: React.KeyboardEvent) => {
111
+ switch (e.key) {
112
+ case 'Escape':
113
+ onClose();
114
+ break;
115
+ case '+':
116
+ case '=':
117
+ zoomIn();
118
+ break;
119
+ case '-':
120
+ zoomOut();
121
+ break;
122
+ case '0':
123
+ resetView();
124
+ break;
125
+ }
126
+ },
127
+ [onClose, zoomIn, zoomOut, resetView],
128
+ );
129
+
130
+ const handleWheel = useCallback(
131
+ (e: WheelEvent) => {
132
+ e.preventDefault();
133
+ e.stopPropagation();
134
+
135
+ setIsWheelZooming(true);
136
+ if (wheelTimeoutRef.current) clearTimeout(wheelTimeoutRef.current);
137
+ wheelTimeoutRef.current = setTimeout(() => setIsWheelZooming(false), 150);
138
+
139
+ const delta = -e.deltaY * WHEEL_SENSITIVITY;
140
+ setScale((s) => clampScale(s + s * delta));
141
+ },
142
+ [clampScale],
143
+ );
144
+
145
+ const handleMouseDown = useCallback(
146
+ (e: MouseEvent) => {
147
+ if (e.button !== 0) return;
148
+ e.preventDefault();
149
+ setIsDragging(true);
150
+ setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
151
+ },
152
+ [position],
153
+ );
154
+
155
+ const handleMouseMove = useCallback(
156
+ (e: MouseEvent) => {
157
+ if (!isDragging) return;
158
+ setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
159
+ },
160
+ [isDragging, dragStart],
161
+ );
162
+
163
+ const handleMouseUp = useCallback(() => setIsDragging(false), []);
164
+
165
+ const getTouchDistance = (touches: React.TouchList): number => {
166
+ if (touches.length !== 2) return 0;
167
+ const dx = touches[0].clientX - touches[1].clientX;
168
+ const dy = touches[0].clientY - touches[1].clientY;
169
+ return Math.hypot(dx, dy);
170
+ };
171
+
172
+ const handleTouchStart = useCallback(
173
+ (e: ReactTouchEvent) => {
174
+ if (e.touches.length === 2) {
175
+ setPinchState({ distance: getTouchDistance(e.touches), scale });
176
+ } else if (e.touches.length === 1) {
177
+ setIsDragging(true);
178
+ setDragStart({
179
+ x: e.touches[0].clientX - position.x,
180
+ y: e.touches[0].clientY - position.y,
181
+ });
182
+ }
183
+ },
184
+ [position, scale],
185
+ );
186
+
187
+ const handleTouchMove = useCallback(
188
+ (e: ReactTouchEvent) => {
189
+ e.preventDefault();
190
+ if (e.touches.length === 2 && pinchState) {
191
+ const distance = getTouchDistance(e.touches);
192
+ setScale(clampScale(pinchState.scale * (distance / pinchState.distance)));
193
+ } else if (e.touches.length === 1 && isDragging) {
194
+ setPosition({
195
+ x: e.touches[0].clientX - dragStart.x,
196
+ y: e.touches[0].clientY - dragStart.y,
197
+ });
198
+ }
199
+ },
200
+ [pinchState, isDragging, dragStart, clampScale],
201
+ );
202
+
203
+ const handleTouchEnd = useCallback(() => {
204
+ setIsDragging(false);
205
+ setPinchState(null);
206
+ }, []);
207
+
208
+ useEffect(() => {
209
+ if (!isOpen) return;
210
+
211
+ setPosition({ x: 0, y: 0 });
212
+ overlayRef.current?.focus();
213
+
214
+ // Wait for DOM to be ready before measuring
215
+ requestAnimationFrame(() => {
216
+ const fitScale = calculateFitScale();
217
+ setBaseScale(fitScale);
218
+ setScale(fitScale);
219
+ });
220
+ }, [isOpen, calculateFitScale]);
221
+
222
+ if (!isOpen) return null;
223
+
224
+ const zoomPercentage = baseScale > 0 ? Math.round((scale / baseScale) * 100) : 100;
225
+ const isAnimating = !isDragging && !isWheelZooming && !pinchState;
226
+
227
+ return (
228
+ <Overlay
229
+ ref={overlayRef}
230
+ onClick={onClose}
231
+ onKeyDown={handleKeyDown}
232
+ tabIndex={0}
233
+ aria-modal="true"
234
+ role="dialog"
235
+ aria-label={labels.dialogLabel || 'SVG viewer'}
236
+ >
237
+ <Viewport
238
+ ref={viewportRef}
239
+ onClick={(e) => e.stopPropagation()}
240
+ onWheel={handleWheel}
241
+ onMouseDown={handleMouseDown}
242
+ onMouseMove={handleMouseMove}
243
+ onMouseUp={handleMouseUp}
244
+ onMouseLeave={handleMouseUp}
245
+ onTouchStart={handleTouchStart}
246
+ onTouchMove={handleTouchMove}
247
+ onTouchEnd={handleTouchEnd}
248
+ $isDragging={isDragging}
249
+ >
250
+ <Content
251
+ ref={contentRef}
252
+ $isAnimating={isAnimating}
253
+ style={{
254
+ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${scale})`,
255
+ }}
256
+ >
257
+ {children}
258
+ </Content>
259
+ <Controls>
260
+ <ControlGroup>
261
+ <Tooltip tip={labels.zoomOut || 'Zoom out'} placement="top">
262
+ <ControlButton
263
+ variant="text"
264
+ size="small"
265
+ icon={<SubtractIcon />}
266
+ onClick={zoomOut}
267
+ disabled={scale <= minScale}
268
+ />
269
+ </Tooltip>
270
+ <ZoomLabel>{zoomPercentage}%</ZoomLabel>
271
+ <Tooltip tip={labels.zoomIn || 'Zoom in'} placement="top">
272
+ <ControlButton
273
+ variant="text"
274
+ size="small"
275
+ icon={<AddIcon />}
276
+ onClick={zoomIn}
277
+ disabled={scale >= maxScale}
278
+ />
279
+ </Tooltip>
280
+ <Divider />
281
+ <Tooltip tip={labels.fitToView || 'Fit to view'} placement="top">
282
+ <ControlButton
283
+ variant="text"
284
+ size="small"
285
+ icon={<FitToViewIcon />}
286
+ onClick={resetView}
287
+ />
288
+ </Tooltip>
289
+ <Tooltip tip={labels.close || 'Close'} placement="top">
290
+ <ControlButton variant="text" size="small" icon={<CloseIcon />} onClick={onClose} />
291
+ </Tooltip>
292
+ </ControlGroup>
293
+ </Controls>
294
+ </Viewport>
295
+ </Overlay>
296
+ );
297
+ }
298
+
299
+ const scaleIn = keyframes`
300
+ from {
301
+ transform: scale(0.9);
302
+ }
303
+ to {
304
+ transform: scale(1);
305
+ }
306
+ `;
307
+
308
+ const slideUp = keyframes`
309
+ from {
310
+ opacity: 0;
311
+ transform: translateX(-50%) translateY(10px);
312
+ }
313
+ to {
314
+ opacity: 1;
315
+ transform: translateX(-50%) translateY(0);
316
+ }
317
+ `;
318
+
319
+ const Overlay = styled.div`
320
+ position: fixed;
321
+ inset: 0;
322
+ background-color: var(--svg-viewer-overlay-bg-color);
323
+ backdrop-filter: blur(var(--spacing-unit));
324
+ z-index: var(--z-index-overlay, 1000);
325
+ display: flex;
326
+ align-items: center;
327
+ justify-content: center;
328
+ padding: var(--spacing-xxl);
329
+
330
+ &:focus {
331
+ outline: none;
332
+ }
333
+
334
+ @media (max-width: 768px) {
335
+ padding: var(--spacing-md);
336
+ }
337
+ `;
338
+
339
+ const Viewport = styled.div<{ $isDragging: boolean }>`
340
+ position: relative;
341
+ width: 100%;
342
+ height: 100%;
343
+ background-color: var(--svg-viewer-bg-color);
344
+ border-radius: var(--svg-viewer-border-radius);
345
+ overflow: hidden;
346
+ cursor: ${({ $isDragging }) => ($isDragging ? 'grabbing' : 'grab')};
347
+ touch-action: none;
348
+ box-shadow: var(--svg-viewer-box-shadow);
349
+ animation: ${scaleIn} 0.25s ease-in-out forwards;
350
+ `;
351
+
352
+ const Content = styled.div<{ $isAnimating: boolean }>`
353
+ position: absolute;
354
+ top: 50%;
355
+ left: 50%;
356
+ transform-origin: center center;
357
+ user-select: none;
358
+ pointer-events: none;
359
+ transition: ${({ $isAnimating }) => ($isAnimating ? 'transform 0.25s ease-in-out' : 'none')};
360
+
361
+ svg {
362
+ display: block;
363
+ max-width: none !important;
364
+ }
365
+ `;
366
+
367
+ const Controls = styled.div`
368
+ position: absolute;
369
+ bottom: var(--spacing-sm);
370
+ left: 50%;
371
+ transform: translateX(-50%);
372
+ z-index: 10;
373
+ animation: ${slideUp} 0.3s ease-out 0.1s backwards;
374
+ `;
375
+
376
+ const ControlGroup = styled.div`
377
+ display: flex;
378
+ align-items: center;
379
+ gap: 2px;
380
+ padding: var(--spacing-xxs);
381
+ background: var(--bg-color-raised);
382
+ border: 1px solid var(--border-color-primary);
383
+ border-radius: var(--border-radius-lg);
384
+ box-shadow: var(--bg-raised-shadow);
385
+ `;
386
+
387
+ const ControlButton = styled(Button)`
388
+ --button-icon-size: 16px;
389
+ `;
390
+
391
+ const ZoomLabel = styled.span`
392
+ min-width: 40px;
393
+ font-size: var(--font-size-sm);
394
+ font-weight: var(--font-weight-semibold);
395
+ color: var(--text-color-secondary);
396
+ text-align: center;
397
+ font-variant-numeric: tabular-nums;
398
+ `;
399
+
400
+ const Divider = styled.div`
401
+ width: 1px;
402
+ height: var(--spacing-base);
403
+ background: var(--border-color-primary);
404
+ margin: 0 var(--spacing-xxs);
405
+ `;
@@ -0,0 +1,5 @@
1
+ import { css } from 'styled-components';
2
+
3
+ export const svgViewerDarkMode = css`
4
+ --svg-viewer-bg-color: var(--color-warm-grey-9);
5
+ `;
@@ -0,0 +1,14 @@
1
+ import { css } from 'styled-components';
2
+
3
+ export const svgViewer = css`
4
+ /**
5
+ * @tokens SVG Viewer
6
+ */
7
+
8
+ --svg-viewer-overlay-bg-color: var(--bg-color-modal-overlay); // @presenter Color
9
+ --svg-viewer-bg-color: var(--bg-color); // @presenter Color
10
+ --svg-viewer-border-radius: var(--border-radius-xl); // @presenter BorderRadius
11
+ --svg-viewer-box-shadow: var(--bg-raised-shadow); // @presenter BoxShadow
12
+
13
+ // @tokens End
14
+ `;
@@ -182,6 +182,12 @@ export const tagDarkMode = css`
182
182
  --tag-bg-color-hover: #3A465F; // @presenter Color
183
183
  }
184
184
 
185
+ .tag-query {
186
+ --tag-color: #68cc97; // @presenter Color
187
+ --tag-bg-color: #1F3D2D; // @presenter Color
188
+ --tag-bg-color-hover: #34654B; // @presenter Color
189
+ }
190
+
185
191
  .tag-put {
186
192
  --tag-color: #e0a663; // @presenter Color
187
193
  --tag-bg-color: #3D2D1B; // @presenter Color