@plannotator/ui 0.22.0
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/README.md +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- package/utils/wideMode.ts +48 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import mermaid from 'mermaid';
|
|
4
|
+
import type { Block } from '../types';
|
|
5
|
+
import { normalizeMermaidSvgMarkup } from './mermaidSvg';
|
|
6
|
+
|
|
7
|
+
mermaid.initialize({
|
|
8
|
+
startOnLoad: false,
|
|
9
|
+
securityLevel: 'strict',
|
|
10
|
+
theme: 'dark',
|
|
11
|
+
themeVariables: {
|
|
12
|
+
primaryColor: '#3b82f6',
|
|
13
|
+
primaryTextColor: '#f8fafc',
|
|
14
|
+
primaryBorderColor: '#475569',
|
|
15
|
+
lineColor: '#64748b',
|
|
16
|
+
secondaryColor: '#1e293b',
|
|
17
|
+
tertiaryColor: '#0f172a',
|
|
18
|
+
background: '#1e293b',
|
|
19
|
+
mainBkg: '#1e293b',
|
|
20
|
+
nodeBorder: '#475569',
|
|
21
|
+
clusterBkg: '#1e293b',
|
|
22
|
+
clusterBorder: '#475569',
|
|
23
|
+
titleColor: '#f8fafc',
|
|
24
|
+
edgeLabelBackground: '#1e293b',
|
|
25
|
+
},
|
|
26
|
+
flowchart: {
|
|
27
|
+
htmlLabels: true,
|
|
28
|
+
curve: 'basis',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface ViewBox {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ZOOM_STEP = 0.25;
|
|
40
|
+
const MIN_ZOOM = 0.25;
|
|
41
|
+
const MAX_ZOOM = 8;
|
|
42
|
+
|
|
43
|
+
function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
|
|
44
|
+
const raw = svgEl.getAttribute('viewBox');
|
|
45
|
+
if (!raw) return null;
|
|
46
|
+
|
|
47
|
+
const values = raw
|
|
48
|
+
.trim()
|
|
49
|
+
.split(/[\s,]+/)
|
|
50
|
+
.map((value) => Number.parseFloat(value));
|
|
51
|
+
|
|
52
|
+
if (values.length !== 4 || values.some((value) => Number.isNaN(value))) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [x, y, width, height] = values;
|
|
57
|
+
if (width <= 0 || height <= 0) return null;
|
|
58
|
+
return { x, y, width, height };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse base viewBox from Mermaid SVG markup before DOM mount
|
|
62
|
+
function parseViewBoxFromMarkup(markup: string): ViewBox | null {
|
|
63
|
+
const viewBoxMatch = markup.match(/viewBox\s*=\s*"([^"]+)"/i);
|
|
64
|
+
if (viewBoxMatch?.[1]) {
|
|
65
|
+
const values = viewBoxMatch[1]
|
|
66
|
+
.trim()
|
|
67
|
+
.split(/[\s,]+/)
|
|
68
|
+
.map((value) => Number.parseFloat(value));
|
|
69
|
+
|
|
70
|
+
if (values.length === 4 && values.every((value) => Number.isFinite(value))) {
|
|
71
|
+
const [x, y, width, height] = values;
|
|
72
|
+
if (width > 0 && height > 0) {
|
|
73
|
+
return { x, y, width, height };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const widthMatch = markup.match(/\bwidth\s*=\s*"([0-9.]+)(?:px)?"/i);
|
|
79
|
+
const heightMatch = markup.match(/\bheight\s*=\s*"([0-9.]+)(?:px)?"/i);
|
|
80
|
+
const width = widthMatch?.[1] ? Number.parseFloat(widthMatch[1]) : NaN;
|
|
81
|
+
const height = heightMatch?.[1] ? Number.parseFloat(heightMatch[1]) : NaN;
|
|
82
|
+
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
|
83
|
+
return { x: 0, y: 0, width, height };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Apply calculated viewBox from zoom and pan state
|
|
90
|
+
function applyView(svgEl: SVGSVGElement, base: ViewBox, zoom: number, pan: { x: number; y: number }): void {
|
|
91
|
+
const zoomedWidth = base.width / zoom;
|
|
92
|
+
const zoomedHeight = base.height / zoom;
|
|
93
|
+
const centerX = base.x + base.width / 2;
|
|
94
|
+
const centerY = base.y + base.height / 2;
|
|
95
|
+
const vbX = centerX - zoomedWidth / 2 + pan.x;
|
|
96
|
+
const vbY = centerY - zoomedHeight / 2 + pan.y;
|
|
97
|
+
svgEl.setAttribute('viewBox', `${vbX} ${vbY} ${zoomedWidth} ${zoomedHeight}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Compute a fitted base viewBox for the current container ratio
|
|
101
|
+
function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox {
|
|
102
|
+
const containerWidth = Math.max(containerRect.width, 1);
|
|
103
|
+
const containerHeight = Math.max(containerRect.height, 1);
|
|
104
|
+
const contentRatio = bounds.width / bounds.height;
|
|
105
|
+
const containerRatio = containerWidth / containerHeight;
|
|
106
|
+
|
|
107
|
+
if (contentRatio > containerRatio) {
|
|
108
|
+
const targetHeight = bounds.width / containerRatio;
|
|
109
|
+
const extra = (targetHeight - bounds.height) / 2;
|
|
110
|
+
return {
|
|
111
|
+
x: bounds.x,
|
|
112
|
+
y: bounds.y - extra,
|
|
113
|
+
width: bounds.width,
|
|
114
|
+
height: targetHeight,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const targetWidth = bounds.height * containerRatio;
|
|
119
|
+
const extra = (targetWidth - bounds.width) / 2;
|
|
120
|
+
return {
|
|
121
|
+
x: bounds.x - extra,
|
|
122
|
+
y: bounds.y,
|
|
123
|
+
width: targetWidth,
|
|
124
|
+
height: bounds.height,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Renders a mermaid diagram block with zoom controls.
|
|
130
|
+
*/
|
|
131
|
+
const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
132
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
133
|
+
const expandedOverlayRef = useRef<HTMLDivElement>(null);
|
|
134
|
+
const [svg, setSvg] = useState('');
|
|
135
|
+
const [error, setError] = useState<string | null>(null);
|
|
136
|
+
const [showSource, setShowSource] = useState(false);
|
|
137
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
138
|
+
|
|
139
|
+
// All zoom/pan state as refs to avoid re-renders
|
|
140
|
+
const zoomLevelRef = useRef(1);
|
|
141
|
+
const isDraggingRef = useRef(false);
|
|
142
|
+
const naturalBoundsRef = useRef<ViewBox | null>(null);
|
|
143
|
+
const baseViewBoxRef = useRef<ViewBox | null>(null);
|
|
144
|
+
const panOffsetRef = useRef({ x: 0, y: 0 });
|
|
145
|
+
const dragStartRef = useRef({ x: 0, y: 0 });
|
|
146
|
+
const panStartRef = useRef({ x: 0, y: 0 });
|
|
147
|
+
|
|
148
|
+
// UI refs for zoom controls
|
|
149
|
+
const zoomInBtnRef = useRef<HTMLButtonElement>(null);
|
|
150
|
+
const zoomOutBtnRef = useRef<HTMLButtonElement>(null);
|
|
151
|
+
const zoomDisplayRef = useRef<HTMLSpanElement>(null);
|
|
152
|
+
|
|
153
|
+
// Update zoom level, viewBox, and UI without React re-render
|
|
154
|
+
const updateZoom = useCallback((newZoom: number) => {
|
|
155
|
+
zoomLevelRef.current = newZoom;
|
|
156
|
+
|
|
157
|
+
if (containerRef.current && baseViewBoxRef.current) {
|
|
158
|
+
const svgEl = containerRef.current.querySelector('svg');
|
|
159
|
+
if (svgEl instanceof SVGSVGElement) {
|
|
160
|
+
applyView(svgEl, baseViewBoxRef.current, newZoom, panOffsetRef.current);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (zoomInBtnRef.current) zoomInBtnRef.current.disabled = newZoom >= MAX_ZOOM;
|
|
165
|
+
if (zoomOutBtnRef.current) zoomOutBtnRef.current.disabled = newZoom <= MIN_ZOOM;
|
|
166
|
+
if (zoomDisplayRef.current) {
|
|
167
|
+
const show = Math.abs(newZoom - 1) > 0.001;
|
|
168
|
+
zoomDisplayRef.current.textContent = show ? `${Math.round(newZoom * 100)}%` : '';
|
|
169
|
+
zoomDisplayRef.current.hidden = !show;
|
|
170
|
+
}
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const fitToCurrentViewport = useCallback(() => {
|
|
174
|
+
if (!containerRef.current || !naturalBoundsRef.current) return;
|
|
175
|
+
|
|
176
|
+
const svgEl = containerRef.current.querySelector('svg');
|
|
177
|
+
if (!(svgEl instanceof SVGSVGElement)) return;
|
|
178
|
+
|
|
179
|
+
const fitted = fitBoundsToContainer(naturalBoundsRef.current, containerRef.current.getBoundingClientRect());
|
|
180
|
+
baseViewBoxRef.current = fitted;
|
|
181
|
+
panOffsetRef.current = { x: 0, y: 0 };
|
|
182
|
+
updateZoom(1);
|
|
183
|
+
applyView(svgEl, fitted, 1, { x: 0, y: 0 });
|
|
184
|
+
}, [updateZoom]);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
let cancelled = false;
|
|
188
|
+
|
|
189
|
+
// Render mermaid diagram
|
|
190
|
+
const renderDiagram = async () => {
|
|
191
|
+
try {
|
|
192
|
+
const id = `mermaid-${block.id}`;
|
|
193
|
+
const { svg: renderedSvg } = await mermaid.render(id, block.content);
|
|
194
|
+
if (!cancelled) {
|
|
195
|
+
const normalizedSvg = normalizeMermaidSvgMarkup(renderedSvg);
|
|
196
|
+
naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg);
|
|
197
|
+
setSvg(normalizedSvg);
|
|
198
|
+
setError(null);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (!cancelled) {
|
|
202
|
+
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
203
|
+
setSvg('');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
renderDiagram();
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
cancelled = true;
|
|
212
|
+
};
|
|
213
|
+
}, [block.content, block.id]);
|
|
214
|
+
|
|
215
|
+
// Reset zoom and pan when content changes
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
zoomLevelRef.current = 1;
|
|
218
|
+
naturalBoundsRef.current = null;
|
|
219
|
+
baseViewBoxRef.current = null;
|
|
220
|
+
panOffsetRef.current = { x: 0, y: 0 };
|
|
221
|
+
setIsExpanded(false);
|
|
222
|
+
}, [block.content]);
|
|
223
|
+
|
|
224
|
+
// Reset zoom and pan when switching from source back to diagram
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (showSource) {
|
|
227
|
+
setIsExpanded(false);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
zoomLevelRef.current = 1;
|
|
232
|
+
panOffsetRef.current = { x: 0, y: 0 };
|
|
233
|
+
baseViewBoxRef.current = null;
|
|
234
|
+
}, [showSource]);
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!isExpanded) return undefined;
|
|
238
|
+
|
|
239
|
+
const previousOverflow = document.body.style.overflow;
|
|
240
|
+
document.body.style.overflow = 'hidden';
|
|
241
|
+
|
|
242
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
243
|
+
if (event.key === 'Escape') {
|
|
244
|
+
setIsExpanded(false);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
249
|
+
|
|
250
|
+
return () => {
|
|
251
|
+
document.body.style.overflow = previousOverflow;
|
|
252
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
253
|
+
};
|
|
254
|
+
}, [isExpanded]);
|
|
255
|
+
|
|
256
|
+
// Compute base viewBox from rendered SVG and apply initial view
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!svg || showSource || !containerRef.current) return;
|
|
259
|
+
|
|
260
|
+
const svgEl = containerRef.current.querySelector('svg');
|
|
261
|
+
if (!(svgEl instanceof SVGSVGElement)) return;
|
|
262
|
+
|
|
263
|
+
svgEl.style.maxWidth = 'none';
|
|
264
|
+
svgEl.style.width = '100%';
|
|
265
|
+
svgEl.style.height = '100%';
|
|
266
|
+
svgEl.style.display = 'block';
|
|
267
|
+
svgEl.style.filter = 'none';
|
|
268
|
+
svgEl.style.willChange = 'auto';
|
|
269
|
+
svgEl.setAttribute('width', '100%');
|
|
270
|
+
svgEl.setAttribute('height', '100%');
|
|
271
|
+
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
272
|
+
|
|
273
|
+
let cancelled = false;
|
|
274
|
+
|
|
275
|
+
const applyInitialView = () => {
|
|
276
|
+
if (cancelled) return;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const base = naturalBoundsRef.current ?? parseViewBox(svgEl);
|
|
280
|
+
|
|
281
|
+
if (!base) return;
|
|
282
|
+
|
|
283
|
+
naturalBoundsRef.current = base;
|
|
284
|
+
fitToCurrentViewport();
|
|
285
|
+
} catch {
|
|
286
|
+
setError('Failed to measure diagram bounds');
|
|
287
|
+
setSvg('');
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const raf = requestAnimationFrame(() => requestAnimationFrame(applyInitialView));
|
|
292
|
+
const timer = window.setTimeout(applyInitialView, 120);
|
|
293
|
+
|
|
294
|
+
return () => {
|
|
295
|
+
cancelled = true;
|
|
296
|
+
cancelAnimationFrame(raf);
|
|
297
|
+
window.clearTimeout(timer);
|
|
298
|
+
};
|
|
299
|
+
}, [fitToCurrentViewport, isExpanded, showSource, svg]);
|
|
300
|
+
|
|
301
|
+
const applyWheelZoomDelta = useCallback((deltaY: number) => {
|
|
302
|
+
if (Math.abs(deltaY) < 0.1) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const delta = deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
|
307
|
+
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevelRef.current + delta));
|
|
308
|
+
updateZoom(newZoom);
|
|
309
|
+
}, [updateZoom]);
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (showSource || !containerRef.current) return;
|
|
313
|
+
|
|
314
|
+
const container = containerRef.current;
|
|
315
|
+
const handleWheel = (event: WheelEvent) => {
|
|
316
|
+
if (Math.abs(event.deltaY) < 0.1) return;
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
applyWheelZoomDelta(event.deltaY);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
container.addEventListener('wheel', handleWheel, { passive: false });
|
|
322
|
+
|
|
323
|
+
return () => {
|
|
324
|
+
container.removeEventListener('wheel', handleWheel);
|
|
325
|
+
};
|
|
326
|
+
}, [applyWheelZoomDelta, showSource]);
|
|
327
|
+
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (showSource || !isExpanded) return;
|
|
330
|
+
|
|
331
|
+
const handleExpandedPinchWheel = (event: WheelEvent) => {
|
|
332
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
333
|
+
if (!expandedOverlayRef.current) return;
|
|
334
|
+
|
|
335
|
+
const eventTarget = event.target;
|
|
336
|
+
if (!(eventTarget instanceof Node) || !expandedOverlayRef.current.contains(eventTarget)) return;
|
|
337
|
+
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
event.stopPropagation();
|
|
340
|
+
applyWheelZoomDelta(event.deltaY);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
window.addEventListener('wheel', handleExpandedPinchWheel, { passive: false, capture: true });
|
|
344
|
+
|
|
345
|
+
return () => {
|
|
346
|
+
window.removeEventListener('wheel', handleExpandedPinchWheel, { capture: true });
|
|
347
|
+
};
|
|
348
|
+
}, [applyWheelZoomDelta, isExpanded, showSource]);
|
|
349
|
+
|
|
350
|
+
const handleZoomIn = useCallback(() => {
|
|
351
|
+
updateZoom(Math.min(zoomLevelRef.current + ZOOM_STEP, MAX_ZOOM));
|
|
352
|
+
}, [updateZoom]);
|
|
353
|
+
|
|
354
|
+
const handleZoomOut = useCallback(() => {
|
|
355
|
+
updateZoom(Math.max(zoomLevelRef.current - ZOOM_STEP, MIN_ZOOM));
|
|
356
|
+
}, [updateZoom]);
|
|
357
|
+
|
|
358
|
+
const handleFitToScreen = useCallback(() => {
|
|
359
|
+
fitToCurrentViewport();
|
|
360
|
+
}, [fitToCurrentViewport]);
|
|
361
|
+
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
if (showSource || !containerRef.current || !naturalBoundsRef.current) return;
|
|
364
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
365
|
+
|
|
366
|
+
const observer = new ResizeObserver(() => {
|
|
367
|
+
if (Math.abs(zoomLevelRef.current - 1) > 0.001) return;
|
|
368
|
+
fitToCurrentViewport();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
observer.observe(containerRef.current);
|
|
372
|
+
return () => observer.disconnect();
|
|
373
|
+
}, [fitToCurrentViewport, isExpanded, showSource, svg]);
|
|
374
|
+
|
|
375
|
+
// Drag-to-pan handlers (all ref-based to avoid re-renders)
|
|
376
|
+
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
|
377
|
+
if (event.button !== 0) return;
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
isDraggingRef.current = true;
|
|
380
|
+
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
|
381
|
+
panStartRef.current = { ...panOffsetRef.current };
|
|
382
|
+
if (containerRef.current) containerRef.current.style.cursor = 'grabbing';
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
const handleMouseMove = useCallback((event: React.MouseEvent) => {
|
|
386
|
+
if (!isDraggingRef.current || !containerRef.current || !baseViewBoxRef.current) return;
|
|
387
|
+
|
|
388
|
+
const svgEl = containerRef.current.querySelector('svg');
|
|
389
|
+
if (!(svgEl instanceof SVGSVGElement)) return;
|
|
390
|
+
|
|
391
|
+
const rect = svgEl.getBoundingClientRect();
|
|
392
|
+
const base = baseViewBoxRef.current;
|
|
393
|
+
const zoom = zoomLevelRef.current;
|
|
394
|
+
const scaleX = (base.width / zoom) / rect.width;
|
|
395
|
+
const scaleY = (base.height / zoom) / rect.height;
|
|
396
|
+
|
|
397
|
+
const dx = event.clientX - dragStartRef.current.x;
|
|
398
|
+
const dy = event.clientY - dragStartRef.current.y;
|
|
399
|
+
|
|
400
|
+
panOffsetRef.current = {
|
|
401
|
+
x: panStartRef.current.x - dx * scaleX,
|
|
402
|
+
y: panStartRef.current.y - dy * scaleY,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
applyView(svgEl, base, zoom, panOffsetRef.current);
|
|
406
|
+
}, []);
|
|
407
|
+
|
|
408
|
+
const stopDragging = useCallback(() => {
|
|
409
|
+
if (!isDraggingRef.current) return;
|
|
410
|
+
isDraggingRef.current = false;
|
|
411
|
+
if (containerRef.current) containerRef.current.style.cursor = 'grab';
|
|
412
|
+
}, []);
|
|
413
|
+
|
|
414
|
+
if (error) {
|
|
415
|
+
return (
|
|
416
|
+
<div className="my-5 rounded-lg border border-destructive/30 bg-destructive/5 overflow-hidden">
|
|
417
|
+
<div className="px-3 py-2 bg-destructive/10 border-b border-destructive/20 flex items-center gap-2">
|
|
418
|
+
<svg className="w-4 h-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
419
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
420
|
+
</svg>
|
|
421
|
+
<span className="text-xs text-destructive font-medium">Mermaid Error</span>
|
|
422
|
+
</div>
|
|
423
|
+
<pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
|
|
424
|
+
<pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
|
|
425
|
+
<code>{block.content}</code>
|
|
426
|
+
</pre>
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const controls = (
|
|
432
|
+
/* Controls container */
|
|
433
|
+
<div className={`absolute top-2 right-2 flex flex-col gap-1 items-center z-10 ${isExpanded ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 transition-opacity'}`}>
|
|
434
|
+
{/* Toggle source/diagram button */}
|
|
435
|
+
<button
|
|
436
|
+
onClick={() => setShowSource(!showSource)}
|
|
437
|
+
className="p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
438
|
+
title={showSource ? 'Show diagram' : 'Show source'}
|
|
439
|
+
>
|
|
440
|
+
{showSource ? (
|
|
441
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
442
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
443
|
+
</svg>
|
|
444
|
+
) : (
|
|
445
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
446
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
|
447
|
+
</svg>
|
|
448
|
+
)}
|
|
449
|
+
</button>
|
|
450
|
+
|
|
451
|
+
{!showSource && svg && (
|
|
452
|
+
<>
|
|
453
|
+
{/* Diagram interaction controls */}
|
|
454
|
+
<div className="flex w-10 flex-col items-center gap-0.5 bg-muted/85 rounded-md p-0.5">
|
|
455
|
+
{/* Expand/exit expanded button */}
|
|
456
|
+
<button
|
|
457
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
458
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
459
|
+
title={isExpanded ? 'Exit expanded view' : 'Expand diagram'}
|
|
460
|
+
aria-label={isExpanded ? 'Exit expanded view' : 'Expand diagram'}
|
|
461
|
+
>
|
|
462
|
+
{isExpanded ? (
|
|
463
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
464
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 10h4V6M18 10h-4V6M6 14h4v4M18 14h-4v4" />
|
|
465
|
+
</svg>
|
|
466
|
+
) : (
|
|
467
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
468
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
|
469
|
+
</svg>
|
|
470
|
+
)}
|
|
471
|
+
</button>
|
|
472
|
+
|
|
473
|
+
{/* Zoom in button */}
|
|
474
|
+
<button
|
|
475
|
+
ref={zoomInBtnRef}
|
|
476
|
+
onClick={handleZoomIn}
|
|
477
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
|
478
|
+
title="Zoom in"
|
|
479
|
+
aria-label="Zoom in"
|
|
480
|
+
>
|
|
481
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
482
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
|
|
483
|
+
</svg>
|
|
484
|
+
</button>
|
|
485
|
+
|
|
486
|
+
{/* Fit to view button */}
|
|
487
|
+
<button
|
|
488
|
+
onClick={handleFitToScreen}
|
|
489
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
490
|
+
title="Fit to view"
|
|
491
|
+
aria-label="Fit to view"
|
|
492
|
+
>
|
|
493
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
494
|
+
<circle cx="12" cy="12" r="4" strokeLinecap="round" strokeLinejoin="round" />
|
|
495
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
|
496
|
+
</svg>
|
|
497
|
+
</button>
|
|
498
|
+
|
|
499
|
+
{/* Zoom out button */}
|
|
500
|
+
<button
|
|
501
|
+
ref={zoomOutBtnRef}
|
|
502
|
+
onClick={handleZoomOut}
|
|
503
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
|
504
|
+
title="Zoom out"
|
|
505
|
+
aria-label="Zoom out"
|
|
506
|
+
>
|
|
507
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
508
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
|
|
509
|
+
</svg>
|
|
510
|
+
</button>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
{/* Zoom percentage badge */}
|
|
514
|
+
<span
|
|
515
|
+
ref={zoomDisplayRef}
|
|
516
|
+
hidden
|
|
517
|
+
className="min-w-10 rounded bg-muted/85 px-1 py-0.5 text-[10px] text-center text-muted-foreground tabular-nums leading-tight"
|
|
518
|
+
/>
|
|
519
|
+
</>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const inlineSource = (
|
|
525
|
+
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
|
|
526
|
+
<code className="hljs font-mono language-mermaid">{block.content}</code>
|
|
527
|
+
</pre>
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const diagramBody = (
|
|
531
|
+
<div
|
|
532
|
+
ref={containerRef}
|
|
533
|
+
data-pinpoint-ignore=""
|
|
534
|
+
className={`rounded-xl bg-muted/30 border border-border/30 overflow-hidden select-none cursor-grab ${isExpanded ? 'h-full min-h-0' : 'h-[min(65vh,36rem)] min-h-[20rem]'}`}
|
|
535
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
536
|
+
onMouseDown={handleMouseDown}
|
|
537
|
+
onMouseMove={handleMouseMove}
|
|
538
|
+
onMouseUp={stopDragging}
|
|
539
|
+
onMouseLeave={stopDragging}
|
|
540
|
+
/>
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
return (
|
|
544
|
+
<>
|
|
545
|
+
<div className="my-5 group relative" data-block-id={block.id}>
|
|
546
|
+
{!isExpanded && controls}
|
|
547
|
+
{showSource || !svg ? inlineSource : !isExpanded ? diagramBody : <div className="rounded-xl border border-border/30 bg-muted/10 h-[min(65vh,36rem)] min-h-[20rem]" />}
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
{!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal(
|
|
551
|
+
<div ref={expandedOverlayRef} className="fixed inset-0 z-[9999] bg-background/90 backdrop-blur-sm p-4 md:p-6">
|
|
552
|
+
<div className="mx-auto flex h-full max-w-[min(96vw,110rem)] flex-col gap-3">
|
|
553
|
+
<div className="flex items-center justify-between gap-4 text-xs text-muted-foreground">
|
|
554
|
+
<span className="truncate">Mermaid diagram</span>
|
|
555
|
+
<button
|
|
556
|
+
onClick={() => setIsExpanded(false)}
|
|
557
|
+
className="rounded-md border border-border/60 bg-card/70 px-2.5 py-1.5 text-foreground hover:bg-card"
|
|
558
|
+
>
|
|
559
|
+
Close
|
|
560
|
+
</button>
|
|
561
|
+
</div>
|
|
562
|
+
<div className="group relative flex-1 min-h-0">
|
|
563
|
+
{controls}
|
|
564
|
+
{diagramBody}
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
</div>,
|
|
568
|
+
document.body
|
|
569
|
+
)}
|
|
570
|
+
</>
|
|
571
|
+
);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
export const MermaidBlock = React.memo(
|
|
575
|
+
MermaidBlockImpl,
|
|
576
|
+
(prev, next) =>
|
|
577
|
+
prev.block.id === next.block.id &&
|
|
578
|
+
prev.block.content === next.block.content,
|
|
579
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { useTheme } from './ThemeProvider';
|
|
3
|
+
|
|
4
|
+
export function ModeToggle() {
|
|
5
|
+
const { theme, setTheme } = useTheme();
|
|
6
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
7
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handleClickOutside = (event: PointerEvent) => {
|
|
11
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
12
|
+
setIsOpen(false);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
document.addEventListener('pointerdown', handleClickOutside);
|
|
16
|
+
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="relative" ref={dropdownRef}>
|
|
21
|
+
<button
|
|
22
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
23
|
+
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
24
|
+
title="Toggle theme"
|
|
25
|
+
>
|
|
26
|
+
{/* Sun */}
|
|
27
|
+
<svg
|
|
28
|
+
className="w-4 h-4 hidden light:block"
|
|
29
|
+
fill="none"
|
|
30
|
+
viewBox="0 0 24 24"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
strokeWidth={2}
|
|
33
|
+
>
|
|
34
|
+
<circle cx="12" cy="12" r="4" />
|
|
35
|
+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
|
36
|
+
</svg>
|
|
37
|
+
{/* Moon */}
|
|
38
|
+
<svg
|
|
39
|
+
className="w-4 h-4 light:hidden"
|
|
40
|
+
fill="none"
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth={2}
|
|
44
|
+
>
|
|
45
|
+
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
|
46
|
+
</svg>
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
{isOpen && (
|
|
50
|
+
<div className="absolute right-0 mt-1 w-32 rounded-lg border border-border bg-popover shadow-xl z-50 overflow-hidden py-1">
|
|
51
|
+
{(['light', 'dark', 'system'] as const).map((t) => (
|
|
52
|
+
<button
|
|
53
|
+
key={t}
|
|
54
|
+
onClick={() => { setTheme(t); setIsOpen(false); }}
|
|
55
|
+
className={`w-full px-3 py-1.5 text-left text-xs capitalize transition-colors ${
|
|
56
|
+
theme === t
|
|
57
|
+
? 'text-primary bg-primary/10 font-medium'
|
|
58
|
+
: 'text-popover-foreground hover:bg-muted'
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
{t}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|