@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.
- package/lib/components/Search/SearchDialog.js +11 -2
- package/lib/components/SvgViewer/SvgViewer.d.ts +15 -0
- package/lib/components/SvgViewer/SvgViewer.js +312 -0
- package/lib/components/SvgViewer/variables.d.ts +1 -0
- package/lib/components/SvgViewer/variables.dark.d.ts +1 -0
- package/lib/components/SvgViewer/variables.dark.js +8 -0
- package/lib/components/SvgViewer/variables.js +17 -0
- package/lib/components/Tag/variables.dark.js +6 -0
- package/lib/components/Tag/variables.js +6 -0
- package/lib/core/styles/dark.js +2 -0
- package/lib/core/styles/global.js +2 -0
- package/lib/core/types/catalog.d.ts +1 -1
- package/lib/core/types/l10n.d.ts +1 -1
- package/lib/core/utils/transform-revisions-to-version-history.js +8 -51
- package/lib/icons/FitToViewIcon/FitToViewIcon.d.ts +9 -0
- package/lib/icons/FitToViewIcon/FitToViewIcon.js +25 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/markdoc/components/Mermaid/Mermaid.js +70 -2
- package/package.json +4 -4
- package/src/components/Search/SearchDialog.tsx +29 -14
- package/src/components/SvgViewer/SvgViewer.tsx +405 -0
- package/src/components/SvgViewer/variables.dark.ts +5 -0
- package/src/components/SvgViewer/variables.ts +14 -0
- package/src/components/Tag/variables.dark.ts +6 -0
- package/src/components/Tag/variables.ts +6 -0
- package/src/core/styles/dark.ts +2 -0
- package/src/core/styles/global.ts +2 -0
- package/src/core/types/catalog.ts +1 -1
- package/src/core/types/l10n.ts +8 -1
- package/src/core/utils/transform-revisions-to-version-history.ts +8 -80
- package/src/icons/FitToViewIcon/FitToViewIcon.tsx +26 -0
- package/src/index.ts +2 -0
- 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 =
|
|
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
|
-
|
|
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
|
+
"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.
|
|
33
|
-
"react-dom": "19.2.
|
|
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.
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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,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
|