@fragments-sdk/viewer 0.2.1
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/LICENSE +84 -0
- package/index.html +28 -0
- package/package.json +71 -0
- package/src/__tests__/a11y-fixes.test.ts +358 -0
- package/src/__tests__/jsx-parser.test.ts +502 -0
- package/src/__tests__/render-utils.test.ts +232 -0
- package/src/__tests__/style-utils.test.ts +404 -0
- package/src/app/index.ts +1 -0
- package/src/assets/fragments-logo.ts +4 -0
- package/src/assets/fragments_logo.png +0 -0
- package/src/components/AccessibilityPanel.tsx +1457 -0
- package/src/components/ActionCapture.tsx +172 -0
- package/src/components/ActionsPanel.tsx +332 -0
- package/src/components/AllVariantsPreview.tsx +78 -0
- package/src/components/App.tsx +604 -0
- package/src/components/BottomPanel.tsx +288 -0
- package/src/components/CodePanel.naming.test.tsx +59 -0
- package/src/components/CodePanel.tsx +118 -0
- package/src/components/CommandPalette.tsx +392 -0
- package/src/components/ComponentDocView.tsx +164 -0
- package/src/components/ComponentGraph.tsx +380 -0
- package/src/components/ComponentHeader.tsx +88 -0
- package/src/components/ContractPanel.tsx +241 -0
- package/src/components/DeviceMockup.tsx +156 -0
- package/src/components/EmptyVariantMessage.tsx +54 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/FigmaEmbed.tsx +238 -0
- package/src/components/FragmentEditor.tsx +525 -0
- package/src/components/FragmentRenderer.tsx +61 -0
- package/src/components/HeaderSearch.tsx +24 -0
- package/src/components/HealthDashboard.tsx +441 -0
- package/src/components/HmrStatusIndicator.tsx +61 -0
- package/src/components/Icons.tsx +479 -0
- package/src/components/InteractionsPanel.tsx +757 -0
- package/src/components/IsolatedPreviewFrame.tsx +390 -0
- package/src/components/IsolatedRender.tsx +113 -0
- package/src/components/KeyboardShortcutsHelp.tsx +53 -0
- package/src/components/LandingPage.tsx +420 -0
- package/src/components/Layout.tsx +27 -0
- package/src/components/LeftSidebar.tsx +472 -0
- package/src/components/LoadErrorMessage.tsx +102 -0
- package/src/components/MultiViewportPreview.tsx +527 -0
- package/src/components/NoVariantsMessage.tsx +59 -0
- package/src/components/PanelShell.tsx +161 -0
- package/src/components/PerformancePanel.tsx +304 -0
- package/src/components/PreviewArea.tsx +254 -0
- package/src/components/PreviewAside.tsx +168 -0
- package/src/components/PreviewFrameHost.tsx +304 -0
- package/src/components/PreviewToolbar.tsx +80 -0
- package/src/components/PropsEditor.tsx +506 -0
- package/src/components/PropsTable.tsx +111 -0
- package/src/components/RelationsSection.tsx +88 -0
- package/src/components/ResizablePanel.tsx +271 -0
- package/src/components/RightSidebar.tsx +102 -0
- package/src/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/components/ScreenshotButton.tsx +90 -0
- package/src/components/ShadowPreview.tsx +204 -0
- package/src/components/Sidebar.tsx +169 -0
- package/src/components/SkeletonLoader.tsx +161 -0
- package/src/components/ThemeProvider.tsx +42 -0
- package/src/components/Toast.tsx +3 -0
- package/src/components/TokenStylePanel.tsx +699 -0
- package/src/components/TopToolbar.tsx +159 -0
- package/src/components/Untitled +1 -0
- package/src/components/UsageSection.tsx +95 -0
- package/src/components/VariantMatrix.tsx +391 -0
- package/src/components/VariantRenderer.tsx +131 -0
- package/src/components/VariantTabs.tsx +40 -0
- package/src/components/ViewerHeader.tsx +69 -0
- package/src/components/ViewerStateSync.tsx +52 -0
- package/src/components/ViewportSelector.tsx +172 -0
- package/src/components/WebMCPDevTools.tsx +503 -0
- package/src/components/WebMCPIntegration.tsx +47 -0
- package/src/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/components/_future/CreatePage.tsx +835 -0
- package/src/components/viewer-utils.ts +16 -0
- package/src/composition-renderer.ts +381 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/ui.ts +166 -0
- package/src/entry.tsx +335 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useA11yCache.ts +383 -0
- package/src/hooks/useA11yService.ts +364 -0
- package/src/hooks/useActions.ts +138 -0
- package/src/hooks/useAppState.ts +147 -0
- package/src/hooks/useCompiledFragments.ts +42 -0
- package/src/hooks/useFigmaIntegration.ts +132 -0
- package/src/hooks/useHmrStatus.ts +109 -0
- package/src/hooks/useKeyboardShortcuts.ts +270 -0
- package/src/hooks/usePreviewBridge.ts +347 -0
- package/src/hooks/useScrollSpy.ts +78 -0
- package/src/hooks/useShadowStyles.ts +221 -0
- package/src/hooks/useUrlState.ts +318 -0
- package/src/hooks/useViewSettings.ts +111 -0
- package/src/intelligence/healthReport.ts +505 -0
- package/src/intelligence/styleDrift.ts +340 -0
- package/src/intelligence/usageScanner.ts +309 -0
- package/src/jsx-parser.ts +486 -0
- package/src/preview-frame-entry.tsx +25 -0
- package/src/preview-frame.html +148 -0
- package/src/render-template.html +68 -0
- package/src/render-utils.ts +311 -0
- package/src/shared/ComponentDocContent.module.scss +10 -0
- package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
- package/src/shared/ComponentDocContent.tsx +274 -0
- package/src/shared/DocsHeaderBar.tsx +129 -0
- package/src/shared/DocsPageAsideHost.tsx +89 -0
- package/src/shared/DocsPageShell.tsx +124 -0
- package/src/shared/DocsSearchCommand.tsx +99 -0
- package/src/shared/DocsSidebarNav.tsx +66 -0
- package/src/shared/PropsTable.module.scss +68 -0
- package/src/shared/PropsTable.module.scss.d.ts +2 -0
- package/src/shared/PropsTable.tsx +76 -0
- package/src/shared/VariantPreviewCard.module.scss +114 -0
- package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
- package/src/shared/VariantPreviewCard.tsx +137 -0
- package/src/shared/docs-data/index.ts +32 -0
- package/src/shared/docs-data/mcp-configs.ts +72 -0
- package/src/shared/docs-data/palettes.ts +75 -0
- package/src/shared/docs-data/setup-examples.ts +55 -0
- package/src/shared/docs-layout.scss +28 -0
- package/src/shared/docs-layout.scss.d.ts +2 -0
- package/src/shared/index.ts +34 -0
- package/src/shared/types.ts +53 -0
- package/src/style-utils.ts +414 -0
- package/src/styles/globals.css +278 -0
- package/src/types/a11y.ts +197 -0
- package/src/utils/a11y-fixes.ts +509 -0
- package/src/utils/actionExport.ts +372 -0
- package/src/utils/colorSchemes.ts +201 -0
- package/src/utils/contrast.ts +246 -0
- package/src/utils/detectRelationships.ts +256 -0
- package/src/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/webmcp/analytics.ts +165 -0
- package/src/webmcp/index.ts +3 -0
- package/src/webmcp/posthog-bridge.ts +39 -0
- package/src/webmcp/runtime-tools.ts +152 -0
- package/src/webmcp/scan-utils.ts +135 -0
- package/src/webmcp/use-tool-analytics.ts +69 -0
- package/src/webmcp/viewer-state.ts +45 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PreviewFrameHost - Iframe-side component that renders components in isolation
|
|
3
|
+
*
|
|
4
|
+
* This component runs inside the preview iframe and:
|
|
5
|
+
* 1. Listens for render requests from the parent window
|
|
6
|
+
* 2. Loads and renders the requested fragment variant
|
|
7
|
+
* 3. Applies theme styling
|
|
8
|
+
* 4. Reports render status back to parent
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useEffect, useRef } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
usePreviewVariantRuntime,
|
|
14
|
+
type FragmentVariant,
|
|
15
|
+
} from '@fragments-sdk/core';
|
|
16
|
+
import { useFrameBridge } from '../hooks/usePreviewBridge.js';
|
|
17
|
+
|
|
18
|
+
// Types for fragment data
|
|
19
|
+
interface PreviewFragmentDefinition {
|
|
20
|
+
meta: {
|
|
21
|
+
name: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
category?: string;
|
|
24
|
+
};
|
|
25
|
+
variants?: FragmentVariant[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FragmentItem {
|
|
29
|
+
path: string;
|
|
30
|
+
fragment: PreviewFragmentDefinition;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Cached fragments
|
|
34
|
+
let cachedFragments: FragmentItem[] | null = null;
|
|
35
|
+
let fragmentsPromise: Promise<FragmentItem[]> | null = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load fragments from the virtual module
|
|
39
|
+
*/
|
|
40
|
+
async function loadFragments(): Promise<FragmentItem[]> {
|
|
41
|
+
if (cachedFragments) {
|
|
42
|
+
return cachedFragments;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (fragmentsPromise) {
|
|
46
|
+
return fragmentsPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fragmentsPromise = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
// @ts-expect-error Virtual module
|
|
52
|
+
const module = await import('virtual:fragments');
|
|
53
|
+
if (module.fragmentsPromise) {
|
|
54
|
+
cachedFragments = await module.fragmentsPromise;
|
|
55
|
+
} else {
|
|
56
|
+
cachedFragments = module.fragments || [];
|
|
57
|
+
}
|
|
58
|
+
return cachedFragments!;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error('[PreviewFrameHost] Failed to load fragments:', error);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
|
|
65
|
+
return fragmentsPromise;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Find a fragment by its path
|
|
70
|
+
*/
|
|
71
|
+
function findFragmentByPath(fragments: FragmentItem[], path: string): FragmentItem | undefined {
|
|
72
|
+
return fragments.find(s => s.path === path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Find a variant by name within a fragment
|
|
77
|
+
*/
|
|
78
|
+
function findVariant(fragment: PreviewFragmentDefinition, variantName: string): FragmentVariant | undefined {
|
|
79
|
+
return fragment.variants?.find(v => v.name === variantName);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type PreviewMode = 'centered' | 'full-bleed';
|
|
83
|
+
|
|
84
|
+
function resolvePreviewMode(fragment: PreviewFragmentDefinition): PreviewMode {
|
|
85
|
+
const name = fragment.meta.name.toLowerCase();
|
|
86
|
+
const category = (fragment.meta.category || '').toLowerCase();
|
|
87
|
+
|
|
88
|
+
if (name.includes('appshell') || name.includes('sidebar') || name.includes('header') || name.includes('layout')) {
|
|
89
|
+
return 'full-bleed';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return 'centered';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Error boundary for catching render errors
|
|
97
|
+
*/
|
|
98
|
+
function ErrorDisplay({ message, stack }: { message: string; stack?: string }) {
|
|
99
|
+
return (
|
|
100
|
+
<div style={{ padding: '16px', color: '#dc2626', background: 'rgba(254, 242, 242, 0.95)', borderRadius: '8px', margin: '16px' }}>
|
|
101
|
+
<div style={{ fontWeight: 500, marginBottom: 8 }}>Render Error</div>
|
|
102
|
+
<div>{message}</div>
|
|
103
|
+
{stack && (
|
|
104
|
+
<pre style={{ marginTop: 8, fontSize: 11, opacity: 0.8 }}>
|
|
105
|
+
{stack}
|
|
106
|
+
</pre>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Loading indicator
|
|
114
|
+
*/
|
|
115
|
+
function LoadingIndicator() {
|
|
116
|
+
return (
|
|
117
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px', gap: '8px', color: '#6b7280' }}>
|
|
118
|
+
<div style={{ width: '16px', height: '16px', border: '2px solid #e5e7eb', borderTopColor: '#3b82f6', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
|
119
|
+
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
120
|
+
<span>Loading component...</span>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Variant renderer that handles async loaders
|
|
127
|
+
*/
|
|
128
|
+
function VariantRenderer({
|
|
129
|
+
variant,
|
|
130
|
+
props,
|
|
131
|
+
mode,
|
|
132
|
+
onRendered,
|
|
133
|
+
onError,
|
|
134
|
+
}: {
|
|
135
|
+
variant: FragmentVariant;
|
|
136
|
+
props?: Record<string, unknown>;
|
|
137
|
+
mode: PreviewMode;
|
|
138
|
+
onRendered: (width: number, height: number) => void;
|
|
139
|
+
onError: (message: string, stack?: string) => void;
|
|
140
|
+
}) {
|
|
141
|
+
const { content, isLoading, error } = usePreviewVariantRuntime({
|
|
142
|
+
variant,
|
|
143
|
+
loadedData: props,
|
|
144
|
+
});
|
|
145
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
146
|
+
const hasReported = useRef(false);
|
|
147
|
+
const lastReportedError = useRef<string | null>(null);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
hasReported.current = false;
|
|
151
|
+
}, [variant, props, mode]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!error) {
|
|
155
|
+
lastReportedError.current = null;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const signature = `${error.message}:${error.stack ?? ''}`;
|
|
160
|
+
if (lastReportedError.current === signature) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lastReportedError.current = signature;
|
|
165
|
+
onError(error.message, error.stack);
|
|
166
|
+
}, [error, onError]);
|
|
167
|
+
|
|
168
|
+
// Report rendered size after content renders
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!content || hasReported.current || isLoading || error) return;
|
|
171
|
+
|
|
172
|
+
// Wait for next frame to ensure DOM has updated
|
|
173
|
+
requestAnimationFrame(() => {
|
|
174
|
+
if (containerRef.current && !hasReported.current) {
|
|
175
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
176
|
+
hasReported.current = true;
|
|
177
|
+
onRendered(rect.width, rect.height);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}, [content, error, isLoading, onRendered]);
|
|
181
|
+
|
|
182
|
+
if (isLoading) {
|
|
183
|
+
return <LoadingIndicator />;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (error) {
|
|
187
|
+
return <ErrorDisplay message={error.message} stack={error.stack} />;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
ref={containerRef}
|
|
193
|
+
style={{
|
|
194
|
+
display: 'block',
|
|
195
|
+
width: mode === 'full-bleed' ? '100%' : 'fit-content',
|
|
196
|
+
maxWidth: '100%',
|
|
197
|
+
minHeight: mode === 'full-bleed' ? '100%' : undefined,
|
|
198
|
+
transition: 'opacity 150ms',
|
|
199
|
+
opacity: content ? 1 : 0,
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{content}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Main PreviewFrameHost component
|
|
209
|
+
*/
|
|
210
|
+
export function PreviewFrameHost() {
|
|
211
|
+
const { renderRequest, theme, notifyReady, notifyRendered, notifyError } = useFrameBridge();
|
|
212
|
+
const [fragments, setFragments] = useState<FragmentItem[] | null>(null);
|
|
213
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
214
|
+
const [currentVariant, setCurrentVariant] = useState<FragmentVariant | null>(null);
|
|
215
|
+
const [currentProps, setCurrentProps] = useState<Record<string, unknown> | undefined>(undefined);
|
|
216
|
+
const [previewMode, setPreviewMode] = useState<PreviewMode>('centered');
|
|
217
|
+
|
|
218
|
+
// Apply theme to document
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (theme === 'dark') {
|
|
221
|
+
document.documentElement.classList.add('dark');
|
|
222
|
+
} else {
|
|
223
|
+
document.documentElement.classList.remove('dark');
|
|
224
|
+
}
|
|
225
|
+
}, [theme]);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
document.body.setAttribute('data-preview-mode', previewMode);
|
|
229
|
+
}, [previewMode]);
|
|
230
|
+
|
|
231
|
+
// Load fragments on mount
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
loadFragments()
|
|
234
|
+
.then(segs => {
|
|
235
|
+
setFragments(segs);
|
|
236
|
+
notifyReady();
|
|
237
|
+
})
|
|
238
|
+
.catch(err => {
|
|
239
|
+
const message = err instanceof Error ? err.message : 'Failed to load fragments';
|
|
240
|
+
setLoadError(message);
|
|
241
|
+
notifyError(message);
|
|
242
|
+
});
|
|
243
|
+
}, [notifyReady, notifyError]);
|
|
244
|
+
|
|
245
|
+
// Handle render requests
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (!renderRequest || !fragments) return;
|
|
248
|
+
|
|
249
|
+
const { fragmentPath, variantName, props } = renderRequest;
|
|
250
|
+
|
|
251
|
+
// Find fragment
|
|
252
|
+
const fragmentItem = findFragmentByPath(fragments, fragmentPath);
|
|
253
|
+
if (!fragmentItem) {
|
|
254
|
+
notifyError(`Fragment not found: ${fragmentPath}`);
|
|
255
|
+
setCurrentVariant(null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Find variant
|
|
260
|
+
const variant = findVariant(fragmentItem.fragment, variantName);
|
|
261
|
+
if (!variant) {
|
|
262
|
+
notifyError(`Variant not found: ${variantName} in ${fragmentPath}`);
|
|
263
|
+
setCurrentVariant(null);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
setPreviewMode(resolvePreviewMode(fragmentItem.fragment));
|
|
268
|
+
setCurrentVariant(variant);
|
|
269
|
+
setCurrentProps(props);
|
|
270
|
+
}, [renderRequest, fragments, notifyError]);
|
|
271
|
+
|
|
272
|
+
// Show loading state
|
|
273
|
+
if (!fragments && !loadError) {
|
|
274
|
+
return <LoadingIndicator />;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Show load error
|
|
278
|
+
if (loadError) {
|
|
279
|
+
return <ErrorDisplay message={loadError} />;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Show waiting state
|
|
283
|
+
if (!currentVariant) {
|
|
284
|
+
return (
|
|
285
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px', gap: '8px', color: '#6b7280' }}>
|
|
286
|
+
<span>Waiting for render request...</span>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Render the variant
|
|
292
|
+
return (
|
|
293
|
+
<VariantRenderer
|
|
294
|
+
key={`${renderRequest?.fragmentPath}-${renderRequest?.variantName}`}
|
|
295
|
+
variant={currentVariant}
|
|
296
|
+
props={currentProps}
|
|
297
|
+
mode={previewMode}
|
|
298
|
+
onRendered={notifyRendered}
|
|
299
|
+
onError={notifyError}
|
|
300
|
+
/>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export default PreviewFrameHost;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect, useCallback } from 'react';
|
|
2
|
+
import { Button, Menu, Stack } from '@fragments-sdk/ui';
|
|
3
|
+
import { ZOOM_LEVELS, type ZoomLevel } from '../constants/ui.js';
|
|
4
|
+
import { ZoomIcon, ChevronDownIcon } from './Icons.js';
|
|
5
|
+
|
|
6
|
+
// Re-export types for consumers
|
|
7
|
+
export type { ZoomLevel };
|
|
8
|
+
|
|
9
|
+
interface PreviewToolbarProps {
|
|
10
|
+
zoom: ZoomLevel;
|
|
11
|
+
onZoomChange: (zoom: ZoomLevel) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PreviewToolbar({
|
|
15
|
+
zoom,
|
|
16
|
+
onZoomChange,
|
|
17
|
+
}: PreviewToolbarProps) {
|
|
18
|
+
// Keyboard shortcuts for zoom
|
|
19
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
20
|
+
// Don't handle if in input/textarea
|
|
21
|
+
const target = e.target as HTMLElement;
|
|
22
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (e.key === '=' || e.key === '+') {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
const currentIndex = ZOOM_LEVELS.indexOf(zoom);
|
|
29
|
+
if (currentIndex < ZOOM_LEVELS.length - 1) {
|
|
30
|
+
onZoomChange(ZOOM_LEVELS[currentIndex + 1]);
|
|
31
|
+
}
|
|
32
|
+
} else if (e.key === '-') {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
const currentIndex = ZOOM_LEVELS.indexOf(zoom);
|
|
35
|
+
if (currentIndex > 0) {
|
|
36
|
+
onZoomChange(ZOOM_LEVELS[currentIndex - 1]);
|
|
37
|
+
}
|
|
38
|
+
} else if (e.key === '0') {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
onZoomChange(100);
|
|
41
|
+
}
|
|
42
|
+
}, [zoom, onZoomChange]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
46
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
47
|
+
}, [handleKeyDown]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Stack direction="row" gap="sm" align="center">
|
|
51
|
+
<Menu>
|
|
52
|
+
<Menu.Trigger asChild>
|
|
53
|
+
<Button variant="ghost" size="sm" title="Zoom level (+/-/0)">
|
|
54
|
+
<Stack direction="row" gap="xs" align="center">
|
|
55
|
+
<span style={{ display: 'inline-flex', width: '14px', height: '14px' }}>
|
|
56
|
+
<ZoomIcon />
|
|
57
|
+
</span>
|
|
58
|
+
<span>{zoom}%</span>
|
|
59
|
+
<span style={{ display: 'inline-flex', width: '12px', height: '12px' }}>
|
|
60
|
+
<ChevronDownIcon />
|
|
61
|
+
</span>
|
|
62
|
+
</Stack>
|
|
63
|
+
</Button>
|
|
64
|
+
</Menu.Trigger>
|
|
65
|
+
<Menu.Content side="bottom" align="start">
|
|
66
|
+
<Menu.RadioGroup
|
|
67
|
+
value={String(zoom)}
|
|
68
|
+
onValueChange={(value: string) => onZoomChange(Number(value) as ZoomLevel)}
|
|
69
|
+
>
|
|
70
|
+
{ZOOM_LEVELS.map((level) => (
|
|
71
|
+
<Menu.RadioItem key={level} value={String(level)}>
|
|
72
|
+
{level}%
|
|
73
|
+
</Menu.RadioItem>
|
|
74
|
+
))}
|
|
75
|
+
</Menu.RadioGroup>
|
|
76
|
+
</Menu.Content>
|
|
77
|
+
</Menu>
|
|
78
|
+
</Stack>
|
|
79
|
+
);
|
|
80
|
+
}
|