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.
- package/.next/standalone/.github/workflows/publish.yml +37 -0
- package/.next/standalone/DESIGN.md +521 -0
- package/.next/standalone/README.md +283 -0
- package/.next/standalone/bin/wiki-viewer.js +158 -0
- package/.next/standalone/next.config.ts +11 -0
- package/.next/standalone/package.json +120 -0
- package/.next/standalone/pnpm-lock.yaml +5654 -0
- package/.next/standalone/pnpm-workspace.yaml +2 -0
- package/.next/standalone/postcss.config.mjs +2 -0
- package/.next/standalone/public/logo.svg +11 -0
- package/.next/standalone/public/public/logo.svg +11 -0
- package/.next/standalone/server.js +44 -0
- package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
- package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
- package/.next/standalone/src/app/api/system/config/route.ts +10 -0
- package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
- package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
- package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
- package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
- package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
- package/.next/standalone/src/app/api/wiki/route.ts +98 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
- package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
- package/.next/standalone/src/app/globals.css +716 -0
- package/.next/standalone/src/app/icon.svg +11 -0
- package/.next/standalone/src/app/layout.tsx +35 -0
- package/.next/standalone/src/app/page.tsx +1509 -0
- package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
- package/.next/standalone/src/components/dir-picker.tsx +393 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
- package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
- package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
- package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
- package/.next/standalone/src/components/editor/editor.tsx +633 -0
- package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
- package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
- package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
- package/.next/standalone/src/components/editor/extensions.ts +148 -0
- package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
- package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
- package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
- package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
- package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
- package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
- package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
- package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
- package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
- package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
- package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
- package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
- package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
- package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
- package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
- package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
- package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
- package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
- package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
- package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
- package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
- package/.next/standalone/src/components/theme-provider.tsx +16 -0
- package/.next/standalone/src/components/theme-toggle.tsx +24 -0
- package/.next/standalone/src/components/ui/badge.tsx +35 -0
- package/.next/standalone/src/components/ui/button.tsx +56 -0
- package/.next/standalone/src/components/ui/card.tsx +63 -0
- package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
- package/.next/standalone/src/components/ui/command.tsx +150 -0
- package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
- package/.next/standalone/src/components/ui/dialog.tsx +119 -0
- package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
- package/.next/standalone/src/components/ui/input.tsx +22 -0
- package/.next/standalone/src/components/ui/label.tsx +25 -0
- package/.next/standalone/src/components/ui/popover.tsx +33 -0
- package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
- package/.next/standalone/src/components/ui/select.tsx +156 -0
- package/.next/standalone/src/components/ui/separator.tsx +30 -0
- package/.next/standalone/src/components/ui/sheet.tsx +140 -0
- package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
- package/.next/standalone/src/components/ui/switch.tsx +28 -0
- package/.next/standalone/src/components/ui/tabs.tsx +54 -0
- package/.next/standalone/src/components/ui/textarea.tsx +21 -0
- package/.next/standalone/src/components/ui/tip.tsx +23 -0
- package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
- package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
- package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
- package/.next/standalone/src/lib/app-runner.ts +231 -0
- package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
- package/.next/standalone/src/lib/config.ts +32 -0
- package/.next/standalone/src/lib/embeds/detect.ts +142 -0
- package/.next/standalone/src/lib/google/detect.ts +107 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
- package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
- package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
- package/.next/standalone/src/lib/root-dir.ts +35 -0
- package/.next/standalone/src/lib/toast.ts +9 -0
- package/.next/standalone/src/lib/utils.ts +6 -0
- package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
- package/.next/standalone/src/stores/editor-store.ts +244 -0
- package/.next/standalone/src/stores/tree-store.ts +52 -0
- package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
- package/.next/standalone/src/types/index.ts +47 -0
- package/.next/standalone/tailwind.config.ts +201 -0
- package/.next/standalone/tsconfig.json +41 -0
- package/README.md +283 -0
- package/bin/wiki-viewer.js +158 -0
- 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 & drop</strong> files
|
|
205
|
+
directly into the page — they'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
|
+
}
|