wiki-viewer 1.0.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.
Files changed (119) hide show
  1. package/.next/standalone/.github/workflows/publish.yml +37 -0
  2. package/.next/standalone/DESIGN.md +521 -0
  3. package/.next/standalone/README.md +283 -0
  4. package/.next/standalone/bin/wiki-viewer.js +158 -0
  5. package/.next/standalone/next.config.ts +11 -0
  6. package/.next/standalone/package.json +120 -0
  7. package/.next/standalone/pnpm-lock.yaml +5654 -0
  8. package/.next/standalone/pnpm-workspace.yaml +2 -0
  9. package/.next/standalone/postcss.config.mjs +2 -0
  10. package/.next/standalone/public/logo.svg +11 -0
  11. package/.next/standalone/public/public/logo.svg +11 -0
  12. package/.next/standalone/server.js +44 -0
  13. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
  14. package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
  15. package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
  16. package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
  17. package/.next/standalone/src/app/api/system/config/route.ts +10 -0
  18. package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
  19. package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
  20. package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
  21. package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
  22. package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
  23. package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
  24. package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
  25. package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
  26. package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
  27. package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
  28. package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
  29. package/.next/standalone/src/app/api/wiki/route.ts +98 -0
  30. package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
  31. package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
  32. package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
  33. package/.next/standalone/src/app/globals.css +716 -0
  34. package/.next/standalone/src/app/icon.svg +11 -0
  35. package/.next/standalone/src/app/layout.tsx +35 -0
  36. package/.next/standalone/src/app/page.tsx +1509 -0
  37. package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
  38. package/.next/standalone/src/components/dir-picker.tsx +393 -0
  39. package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
  40. package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
  41. package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
  42. package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
  43. package/.next/standalone/src/components/editor/editor.tsx +633 -0
  44. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
  45. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
  46. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
  47. package/.next/standalone/src/components/editor/extensions.ts +148 -0
  48. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
  49. package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
  50. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
  51. package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
  52. package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
  53. package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
  54. package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
  55. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
  56. package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
  57. package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
  58. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
  59. package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
  60. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
  61. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
  62. package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
  63. package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
  64. package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
  65. package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
  66. package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
  67. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
  68. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
  69. package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
  70. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
  71. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
  72. package/.next/standalone/src/components/theme-provider.tsx +16 -0
  73. package/.next/standalone/src/components/theme-toggle.tsx +24 -0
  74. package/.next/standalone/src/components/ui/badge.tsx +35 -0
  75. package/.next/standalone/src/components/ui/button.tsx +56 -0
  76. package/.next/standalone/src/components/ui/card.tsx +63 -0
  77. package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
  78. package/.next/standalone/src/components/ui/command.tsx +150 -0
  79. package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
  80. package/.next/standalone/src/components/ui/dialog.tsx +119 -0
  81. package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
  82. package/.next/standalone/src/components/ui/input.tsx +22 -0
  83. package/.next/standalone/src/components/ui/label.tsx +25 -0
  84. package/.next/standalone/src/components/ui/popover.tsx +33 -0
  85. package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
  86. package/.next/standalone/src/components/ui/select.tsx +156 -0
  87. package/.next/standalone/src/components/ui/separator.tsx +30 -0
  88. package/.next/standalone/src/components/ui/sheet.tsx +140 -0
  89. package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
  90. package/.next/standalone/src/components/ui/switch.tsx +28 -0
  91. package/.next/standalone/src/components/ui/tabs.tsx +54 -0
  92. package/.next/standalone/src/components/ui/textarea.tsx +21 -0
  93. package/.next/standalone/src/components/ui/tip.tsx +23 -0
  94. package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
  95. package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
  96. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
  97. package/.next/standalone/src/lib/app-runner.ts +231 -0
  98. package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
  99. package/.next/standalone/src/lib/config.ts +32 -0
  100. package/.next/standalone/src/lib/embeds/detect.ts +142 -0
  101. package/.next/standalone/src/lib/google/detect.ts +107 -0
  102. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
  103. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
  104. package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
  105. package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
  106. package/.next/standalone/src/lib/root-dir.ts +35 -0
  107. package/.next/standalone/src/lib/toast.ts +9 -0
  108. package/.next/standalone/src/lib/utils.ts +6 -0
  109. package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
  110. package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
  111. package/.next/standalone/src/stores/editor-store.ts +244 -0
  112. package/.next/standalone/src/stores/tree-store.ts +52 -0
  113. package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
  114. package/.next/standalone/src/types/index.ts +47 -0
  115. package/.next/standalone/tailwind.config.ts +201 -0
  116. package/.next/standalone/tsconfig.json +41 -0
  117. package/README.md +283 -0
  118. package/bin/wiki-viewer.js +158 -0
  119. package/package.json +120 -0
@@ -0,0 +1,211 @@
1
+ "use client";
2
+
3
+ import { Clipboard, Link as LinkIcon, Upload } from "lucide-react";
4
+ import { useRef, useState } from "react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export type MediaKind = "image" | "video" | "file";
8
+
9
+ interface Props {
10
+ kind: MediaKind;
11
+ pagePath: string;
12
+ onCancel: () => void;
13
+ onInsert: (payload: { url: string; alt?: string; mimeType?: string }) => void;
14
+ anchor: { top: number; left: number };
15
+ }
16
+
17
+ async function uploadFile(
18
+ pagePath: string,
19
+ file: File,
20
+ ): Promise<{ url: string; filename: string } | null> {
21
+ const formData = new FormData();
22
+ formData.append("file", file);
23
+ try {
24
+ const res = await fetch(`/api/upload/${pagePath}`, {
25
+ method: "POST",
26
+ body: formData,
27
+ });
28
+ if (!res.ok) return null;
29
+ const data = await res.json();
30
+ return { url: data.url, filename: data.filename };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function mimeFor(kind: MediaKind) {
37
+ if (kind === "image") return "image/*";
38
+ if (kind === "video") return "video/*";
39
+ return "*/*";
40
+ }
41
+
42
+ function titleFor(kind: MediaKind) {
43
+ if (kind === "image") return "Insert image";
44
+ if (kind === "video") return "Insert video";
45
+ return "Attach file";
46
+ }
47
+
48
+ export function MediaPopover({
49
+ kind,
50
+ pagePath,
51
+ onCancel,
52
+ onInsert,
53
+ anchor,
54
+ }: Props) {
55
+ const [tab, setTab] = useState<"upload" | "url">("upload");
56
+ const [url, setUrl] = useState("");
57
+ const [uploading, setUploading] = useState(false);
58
+ const [err, setErr] = useState<string | null>(null);
59
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
60
+
61
+ const handleFiles = async (files: FileList | null) => {
62
+ if (!files || files.length === 0) return;
63
+ setUploading(true);
64
+ setErr(null);
65
+ try {
66
+ const file = files[0];
67
+ const result = await uploadFile(pagePath, file);
68
+ if (!result) {
69
+ setErr("Upload failed.");
70
+ return;
71
+ }
72
+ onInsert({ url: result.url, alt: file.name, mimeType: file.type });
73
+ } finally {
74
+ setUploading(false);
75
+ }
76
+ };
77
+
78
+ const handleUrl = () => {
79
+ const trimmed = url.trim();
80
+ if (!trimmed) return;
81
+ onInsert({ url: trimmed });
82
+ };
83
+
84
+ return (
85
+ <div
86
+ className="absolute z-50 w-[360px] bg-popover border border-border rounded-xl shadow-e-4 overflow-hidden"
87
+ style={{ top: anchor.top, left: anchor.left }}
88
+ onMouseDown={(e) => e.stopPropagation()}
89
+ >
90
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
91
+ <div className="text-[12px] font-medium">{titleFor(kind)}</div>
92
+ <button
93
+ type="button"
94
+ onClick={onCancel}
95
+ className="text-[11px] text-muted-foreground hover:text-foreground"
96
+ >
97
+ Cancel
98
+ </button>
99
+ </div>
100
+
101
+ <div className="flex border-b border-border bg-muted/30">
102
+ <button
103
+ type="button"
104
+ onClick={() => setTab("upload")}
105
+ className={cn(
106
+ "flex-1 py-1.5 text-[11px] flex items-center justify-center gap-1.5 transition-colors",
107
+ tab === "upload"
108
+ ? "bg-background text-foreground border-b-2 border-primary"
109
+ : "text-muted-foreground hover:text-foreground",
110
+ )}
111
+ >
112
+ <Upload className="w-3 h-3" /> Upload
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onClick={() => setTab("url")}
117
+ className={cn(
118
+ "flex-1 py-1.5 text-[11px] flex items-center justify-center gap-1.5 transition-colors",
119
+ tab === "url"
120
+ ? "bg-background text-foreground border-b-2 border-primary"
121
+ : "text-muted-foreground hover:text-foreground",
122
+ )}
123
+ >
124
+ <LinkIcon className="w-3 h-3" /> From URL
125
+ </button>
126
+ </div>
127
+
128
+ {tab === "upload" ? (
129
+ <div
130
+ className="p-4"
131
+ onDragOver={(e) => {
132
+ e.preventDefault();
133
+ e.stopPropagation();
134
+ }}
135
+ onDrop={(e) => {
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ handleFiles(e.dataTransfer.files);
139
+ }}
140
+ >
141
+ <button
142
+ type="button"
143
+ onClick={() => fileInputRef.current?.click()}
144
+ disabled={uploading}
145
+ className="w-full border-2 border-dashed border-border rounded-sm py-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-accent/30 transition-colors"
146
+ >
147
+ <Upload className="w-5 h-5 mb-1.5" />
148
+ <span className="text-[12px] font-medium">
149
+ {uploading ? "Uploading…" : `Click to upload ${kind}`}
150
+ </span>
151
+ <span className="text-[10px] mt-0.5">or drop a file here</span>
152
+ </button>
153
+ <input
154
+ ref={fileInputRef}
155
+ type="file"
156
+ accept={mimeFor(kind)}
157
+ className="hidden"
158
+ onChange={(e) => handleFiles(e.target.files)}
159
+ />
160
+ {err && (
161
+ <div className="mt-2 text-[11px] text-destructive">{err}</div>
162
+ )}
163
+ </div>
164
+ ) : (
165
+ <div className="p-3 space-y-2">
166
+ <input
167
+ type="url"
168
+ autoFocus
169
+ value={url}
170
+ placeholder={
171
+ kind === "image"
172
+ ? "https://example.com/image.png"
173
+ : kind === "video"
174
+ ? "https://example.com/video.mp4"
175
+ : "https://example.com/file"
176
+ }
177
+ onChange={(e) => setUrl(e.target.value)}
178
+ onKeyDown={(e) => {
179
+ if (e.key === "Enter") {
180
+ e.preventDefault();
181
+ handleUrl();
182
+ } else if (e.key === "Escape") {
183
+ onCancel();
184
+ }
185
+ }}
186
+ className="w-full bg-background border border-border rounded-sm px-2.5 py-1.5 text-[12px] focus:outline-none focus:ring-2 focus:ring-primary/30"
187
+ />
188
+ <button
189
+ type="button"
190
+ onClick={handleUrl}
191
+ disabled={!url.trim()}
192
+ className="w-full py-1.5 text-[12px] rounded-sm bg-primary text-primary-foreground disabled:opacity-50 hover:bg-primary/90"
193
+ >
194
+ Insert
195
+ </button>
196
+ </div>
197
+ )}
198
+
199
+ <div className="px-3 py-2 border-t border-border bg-muted/30 text-[10px] text-muted-foreground flex items-start gap-1.5">
200
+ <Clipboard className="w-3 h-3 shrink-0 mt-[1px]" />
201
+ <span>
202
+ Tip: you can also{" "}
203
+ <strong className="text-foreground/80">paste</strong> or{" "}
204
+ <strong className="text-foreground/80">drag &amp; drop</strong> files
205
+ directly into the page — they&apos;ll be saved alongside this page
206
+ automatically.
207
+ </span>
208
+ </div>
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { Download, ExternalLink } from "lucide-react";
4
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface MediaViewerProps {
8
+ path: string;
9
+ title: string;
10
+ type: "video" | "audio";
11
+ }
12
+
13
+ export function MediaViewer({ path, type }: MediaViewerProps) {
14
+ const src = `/api/assets/${path}`;
15
+ const filename = path.split("/").pop() || path;
16
+ const ext = filename.includes(".")
17
+ ? filename.split(".").pop()?.toUpperCase()
18
+ : type.toUpperCase();
19
+
20
+ return (
21
+ <div className="flex-1 flex flex-col overflow-hidden">
22
+ <ViewerToolbar path={path} badge={ext}>
23
+ <Button
24
+ variant="ghost"
25
+ size="sm"
26
+ className="h-7 gap-1.5 text-xs"
27
+ onClick={() => {
28
+ const a = document.createElement("a");
29
+ a.href = src;
30
+ a.download = filename;
31
+ a.click();
32
+ }}
33
+ >
34
+ <Download className="h-3.5 w-3.5" />
35
+ Download
36
+ </Button>
37
+ <Button
38
+ variant="ghost"
39
+ size="sm"
40
+ className="h-7 gap-1.5 text-xs"
41
+ onClick={() => window.open(src, "_blank")}
42
+ >
43
+ <ExternalLink className="h-3.5 w-3.5" />
44
+ Open in new tab
45
+ </Button>
46
+ </ViewerToolbar>
47
+ <div className="flex-1 overflow-auto flex items-center justify-center bg-[#1a1a1a] p-8">
48
+ {type === "video" ? (
49
+ <video
50
+ src={src}
51
+ controls
52
+ className="max-w-full max-h-full rounded-md shadow-lg"
53
+ >
54
+ Your browser does not support the video element.
55
+ </video>
56
+ ) : (
57
+ <div className="w-full max-w-lg space-y-4">
58
+ <div className="text-center text-muted-foreground text-sm">
59
+ {filename}
60
+ </div>
61
+ <audio src={src} controls className="w-full">
62
+ Your browser does not support the audio element.
63
+ </audio>
64
+ </div>
65
+ )}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,285 @@
1
+ "use client";
2
+
3
+ import {
4
+ Check,
5
+ Code2,
6
+ Copy,
7
+ Download,
8
+ Eye,
9
+ Maximize,
10
+ ZoomIn,
11
+ ZoomOut,
12
+ } from "lucide-react";
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
15
+ import { Button } from "@/components/ui/button";
16
+
17
+ interface MermaidViewerProps {
18
+ path: string;
19
+ title: string;
20
+ }
21
+
22
+ export function MermaidViewer({ path, title }: MermaidViewerProps) {
23
+ const [source, setSource] = useState("");
24
+ const [svg, setSvg] = useState("");
25
+ const [error, setError] = useState("");
26
+ const [loading, setLoading] = useState(true);
27
+ const [showSource, setShowSource] = useState(false);
28
+ const [copied, setCopied] = useState(false);
29
+ const [zoom, setZoom] = useState(1);
30
+ const [pan, setPan] = useState({ x: 0, y: 0 });
31
+ const [isPanning, setIsPanning] = useState(false);
32
+ const panStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
33
+ const containerRef = useRef<HTMLDivElement>(null);
34
+ const viewportRef = useRef<HTMLDivElement>(null);
35
+ const renderIdRef = useRef(0);
36
+
37
+ const ZOOM_STEP = 0.25;
38
+ const ZOOM_MIN = 0.25;
39
+ const ZOOM_MAX = 5;
40
+
41
+ const zoomIn = () => setZoom((z) => Math.min(z + ZOOM_STEP, ZOOM_MAX));
42
+ const zoomOut = () => setZoom((z) => Math.max(z - ZOOM_STEP, ZOOM_MIN));
43
+ const resetView = () => {
44
+ setZoom(1);
45
+ setPan({ x: 0, y: 0 });
46
+ };
47
+
48
+ // Mouse wheel zoom
49
+ const handleWheel = useCallback((e: React.WheelEvent) => {
50
+ if (e.ctrlKey || e.metaKey) {
51
+ e.preventDefault();
52
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
53
+ setZoom((z) => Math.min(Math.max(z + delta, ZOOM_MIN), ZOOM_MAX));
54
+ }
55
+ }, []);
56
+
57
+ // Pan via mouse drag
58
+ const handlePointerDown = useCallback(
59
+ (e: React.PointerEvent) => {
60
+ if (e.button !== 0) return;
61
+ setIsPanning(true);
62
+ panStart.current = {
63
+ x: e.clientX,
64
+ y: e.clientY,
65
+ panX: pan.x,
66
+ panY: pan.y,
67
+ };
68
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
69
+ },
70
+ [pan],
71
+ );
72
+
73
+ const handlePointerMove = useCallback(
74
+ (e: React.PointerEvent) => {
75
+ if (!isPanning) return;
76
+ setPan({
77
+ x: panStart.current.panX + (e.clientX - panStart.current.x),
78
+ y: panStart.current.panY + (e.clientY - panStart.current.y),
79
+ });
80
+ },
81
+ [isPanning],
82
+ );
83
+
84
+ const handlePointerUp = useCallback(() => {
85
+ setIsPanning(false);
86
+ }, []);
87
+
88
+ const assetUrl = `/api/assets/${path}`;
89
+ const _filename = path.split("/").pop() || path;
90
+
91
+ const fetchAndRender = useCallback(async () => {
92
+ setLoading(true);
93
+ setError("");
94
+ try {
95
+ const res = await fetch(assetUrl);
96
+ if (!res.ok) throw new Error("Failed to fetch file");
97
+ const text = await res.text();
98
+ setSource(text);
99
+
100
+ const mermaid = (await import("mermaid")).default;
101
+ mermaid.initialize({
102
+ startOnLoad: false,
103
+ theme: document.documentElement.classList.contains("dark")
104
+ ? "dark"
105
+ : "default",
106
+ securityLevel: "loose",
107
+ suppressErrorRendering: true,
108
+ });
109
+
110
+ // Validate syntax first to avoid mermaid injecting error SVGs into the DOM
111
+ await mermaid.parse(text.trim());
112
+
113
+ const id = `mermaid-${++renderIdRef.current}`;
114
+ const { svg: rendered } = await mermaid.render(id, text.trim());
115
+ setSvg(rendered);
116
+ } catch (err) {
117
+ // Clean up any error elements mermaid may have injected into the DOM
118
+ document
119
+ .querySelectorAll('[id^="dmermaid-"], [id^="d"]:has(> .error-icon)')
120
+ .forEach((el) => el.remove());
121
+ setError(err instanceof Error ? err.message : "Failed to render diagram");
122
+ } finally {
123
+ setLoading(false);
124
+ }
125
+ }, [assetUrl]);
126
+
127
+ useEffect(() => {
128
+ void fetchAndRender();
129
+ }, [fetchAndRender]);
130
+
131
+ const copySource = () => {
132
+ navigator.clipboard.writeText(source);
133
+ setCopied(true);
134
+ setTimeout(() => setCopied(false), 2000);
135
+ };
136
+
137
+ const downloadSvg = () => {
138
+ if (!svg) return;
139
+ const blob = new Blob([svg], { type: "image/svg+xml" });
140
+ const url = URL.createObjectURL(blob);
141
+ const a = document.createElement("a");
142
+ a.href = url;
143
+ a.download = `${title}.svg`;
144
+ a.click();
145
+ URL.revokeObjectURL(url);
146
+ };
147
+
148
+ return (
149
+ <div className="flex-1 flex flex-col overflow-hidden">
150
+ <ViewerToolbar path={path} badge="MERMAID">
151
+ <Button
152
+ variant="ghost"
153
+ size="sm"
154
+ className={`h-7 gap-1.5 text-xs ${showSource ? "bg-muted" : ""}`}
155
+ onClick={() => setShowSource((v) => !v)}
156
+ >
157
+ {showSource ? (
158
+ <Eye className="h-3.5 w-3.5" />
159
+ ) : (
160
+ <Code2 className="h-3.5 w-3.5" />
161
+ )}
162
+ {showSource ? "Diagram" : "Code"}
163
+ </Button>
164
+ <Button
165
+ variant="ghost"
166
+ size="sm"
167
+ className="h-7 gap-1.5 text-xs"
168
+ onClick={copySource}
169
+ >
170
+ {copied ? (
171
+ <Check className="h-3.5 w-3.5 text-green-500" />
172
+ ) : (
173
+ <Copy className="h-3.5 w-3.5" />
174
+ )}
175
+ {copied ? "Copied" : "Copy"}
176
+ </Button>
177
+ {svg && !showSource && (
178
+ <>
179
+ <div className="h-4 w-px bg-border mx-0.5" />
180
+ <Button
181
+ variant="ghost"
182
+ size="sm"
183
+ className="h-7 w-7 p-0"
184
+ onClick={zoomOut}
185
+ title="Zoom out"
186
+ >
187
+ <ZoomOut className="h-3.5 w-3.5" />
188
+ </Button>
189
+ <span className="text-[11px] text-muted-foreground tabular-nums w-10 text-center select-none">
190
+ {Math.round(zoom * 100)}%
191
+ </span>
192
+ <Button
193
+ variant="ghost"
194
+ size="sm"
195
+ className="h-7 w-7 p-0"
196
+ onClick={zoomIn}
197
+ title="Zoom in"
198
+ >
199
+ <ZoomIn className="h-3.5 w-3.5" />
200
+ </Button>
201
+ <Button
202
+ variant="ghost"
203
+ size="sm"
204
+ className="h-7 w-7 p-0"
205
+ onClick={resetView}
206
+ title="Reset view"
207
+ >
208
+ <Maximize className="h-3.5 w-3.5" />
209
+ </Button>
210
+ <div className="h-4 w-px bg-border mx-0.5" />
211
+ </>
212
+ )}
213
+ {svg && (
214
+ <Button
215
+ variant="ghost"
216
+ size="sm"
217
+ className="h-7 gap-1.5 text-xs"
218
+ onClick={downloadSvg}
219
+ >
220
+ <Download className="h-3.5 w-3.5" />
221
+ SVG
222
+ </Button>
223
+ )}
224
+ </ViewerToolbar>
225
+ <div className="flex-1 overflow-auto">
226
+ {loading ? (
227
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
228
+ Rendering diagram...
229
+ </div>
230
+ ) : showSource ? (
231
+ <pre className="p-4 text-[13px] leading-relaxed font-mono bg-[#1e1e1e]">
232
+ <code>
233
+ {source.split("\n").map((line, i) => (
234
+ <div key={i} className="flex">
235
+ <span className="inline-block w-12 pr-4 text-right text-[#858585] select-none shrink-0">
236
+ {i + 1}
237
+ </span>
238
+ <span className="text-[#d4d4d4] flex-1">{line || "\n"}</span>
239
+ </div>
240
+ ))}
241
+ </code>
242
+ </pre>
243
+ ) : error ? (
244
+ <div className="flex flex-col items-center justify-center h-full gap-4 text-sm p-8">
245
+ <div className="flex flex-col items-center gap-2">
246
+ <div className="h-10 w-10 rounded-full bg-red-500/10 flex items-center justify-center">
247
+ <Code2 className="h-5 w-5 text-red-500" />
248
+ </div>
249
+ <p className="text-red-500 font-medium">Diagram syntax error</p>
250
+ </div>
251
+ <pre className="text-muted-foreground text-xs max-w-lg text-left bg-muted rounded-md p-3 overflow-auto whitespace-pre-wrap">
252
+ {error}
253
+ </pre>
254
+ <Button
255
+ variant="outline"
256
+ size="sm"
257
+ onClick={() => setShowSource(true)}
258
+ >
259
+ View source to fix
260
+ </Button>
261
+ </div>
262
+ ) : (
263
+ <div
264
+ ref={viewportRef}
265
+ className="relative w-full h-full overflow-hidden"
266
+ style={{ cursor: isPanning ? "grabbing" : "grab" }}
267
+ onWheel={handleWheel}
268
+ onPointerDown={handlePointerDown}
269
+ onPointerMove={handlePointerMove}
270
+ onPointerUp={handlePointerUp}
271
+ >
272
+ <div
273
+ ref={containerRef}
274
+ className="flex items-center justify-center p-8 min-h-full [&_svg]:max-w-full origin-center select-none"
275
+ style={{
276
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
277
+ }}
278
+ dangerouslySetInnerHTML={{ __html: svg }}
279
+ />
280
+ </div>
281
+ )}
282
+ </div>
283
+ </div>
284
+ );
285
+ }