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,633 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cellAround, isInTable } from "@tiptap/pm/tables";
|
|
4
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
5
|
+
import { Code2, FilePlus, Loader2, Sparkles } from "lucide-react";
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { findNodeByPath } from "@/lib/cabinets/tree";
|
|
8
|
+
import { markdownToHtml } from "@/lib/markdown/to-html";
|
|
9
|
+
import { htmlToMarkdown } from "@/lib/markdown/to-markdown";
|
|
10
|
+
import { useAIPanelStore } from "@/stores/ai-panel-store";
|
|
11
|
+
import { useEditorStore } from "@/stores/editor-store";
|
|
12
|
+
import { useTreeStore } from "@/stores/tree-store";
|
|
13
|
+
import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
|
|
14
|
+
import type { TreeNode } from "@/types";
|
|
15
|
+
import { EditorBubbleMenu } from "./bubble-menu";
|
|
16
|
+
import { EditorToolbar } from "./editor-toolbar";
|
|
17
|
+
import { editorExtensions } from "./extensions";
|
|
18
|
+
import { FolderIndex } from "./folder-index";
|
|
19
|
+
import { SlashCommands } from "./slash-commands";
|
|
20
|
+
import { TableMenu } from "./table-menu";
|
|
21
|
+
import {
|
|
22
|
+
useWikiLinkCreate,
|
|
23
|
+
type WikiCreateResult,
|
|
24
|
+
} from "./wiki-link-create-dialog";
|
|
25
|
+
import { WikiLinkPicker } from "./wiki-link-picker";
|
|
26
|
+
|
|
27
|
+
async function uploadFile(
|
|
28
|
+
pagePath: string,
|
|
29
|
+
file: File,
|
|
30
|
+
): Promise<string | null> {
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append("file", file);
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`/api/upload/${pagePath}`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: formData,
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) return null;
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
return data.url;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function flattenTree(nodes: TreeNode[]): { path: string; name: string }[] {
|
|
47
|
+
const result: { path: string; name: string }[] = [];
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
result.push({ path: node.path, name: node.name });
|
|
50
|
+
if (node.children) result.push(...flattenTree(node.children));
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findPageBySlug(
|
|
56
|
+
slug: string,
|
|
57
|
+
currentPath: string | null,
|
|
58
|
+
nodes: TreeNode[],
|
|
59
|
+
): string | null {
|
|
60
|
+
const allPages = flattenTree(nodes);
|
|
61
|
+
// The slug matches the last segment of the path
|
|
62
|
+
const matches = allPages.filter(
|
|
63
|
+
(p) => p.name === slug || p.path.endsWith(`/${slug}`),
|
|
64
|
+
);
|
|
65
|
+
if (matches.length === 0) return null;
|
|
66
|
+
if (matches.length === 1) return matches[0].path;
|
|
67
|
+
|
|
68
|
+
// Prefer sibling pages (same parent directory as current page)
|
|
69
|
+
if (currentPath) {
|
|
70
|
+
const parentDir = currentPath.includes("/")
|
|
71
|
+
? currentPath.substring(0, currentPath.lastIndexOf("/"))
|
|
72
|
+
: "";
|
|
73
|
+
const sibling = matches.find(
|
|
74
|
+
(m) => m.path === (parentDir ? `${parentDir}/${slug}` : slug),
|
|
75
|
+
);
|
|
76
|
+
if (sibling) return sibling.path;
|
|
77
|
+
}
|
|
78
|
+
return matches[0].path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function navigateToPage(
|
|
82
|
+
targetPath: string,
|
|
83
|
+
selectPage: (path: string) => void,
|
|
84
|
+
expandPath: (path: string) => void,
|
|
85
|
+
) {
|
|
86
|
+
const parts = targetPath.split("/");
|
|
87
|
+
for (let i = 1; i < parts.length; i++) {
|
|
88
|
+
expandPath(parts.slice(0, i).join("/"));
|
|
89
|
+
}
|
|
90
|
+
selectPage(targetPath);
|
|
91
|
+
useEditorStore.getState().loadPage(targetPath);
|
|
92
|
+
// Scroll editor container to top
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
document.querySelector("[data-editor-scroll]")?.scrollTo(0, 0);
|
|
95
|
+
}, 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveInternalLink(
|
|
99
|
+
href: string,
|
|
100
|
+
currentPath: string | null,
|
|
101
|
+
nodes: TreeNode[],
|
|
102
|
+
): string | null {
|
|
103
|
+
const allPages = flattenTree(nodes);
|
|
104
|
+
|
|
105
|
+
// Clean up the href: strip .md extension, leading ./ or /
|
|
106
|
+
const linkPath = href
|
|
107
|
+
.replace(/\.md$/, "")
|
|
108
|
+
.replace(/^\.\//, "")
|
|
109
|
+
.replace(/^\//, "");
|
|
110
|
+
|
|
111
|
+
// 1. Try as absolute path (exact match in tree)
|
|
112
|
+
const exactMatch = allPages.find((p) => p.path === linkPath);
|
|
113
|
+
if (exactMatch) return exactMatch.path;
|
|
114
|
+
|
|
115
|
+
// 2. Try relative to current page's directory
|
|
116
|
+
if (currentPath) {
|
|
117
|
+
const parentDir = currentPath.includes("/")
|
|
118
|
+
? currentPath.substring(0, currentPath.lastIndexOf("/"))
|
|
119
|
+
: "";
|
|
120
|
+
const relativePath = parentDir ? `${parentDir}/${linkPath}` : linkPath;
|
|
121
|
+
const relMatch = allPages.find((p) => p.path === relativePath);
|
|
122
|
+
if (relMatch) return relMatch.path;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3. Try matching by last segment (slug-style lookup)
|
|
126
|
+
const slug = linkPath.includes("/") ? linkPath.split("/").pop()! : linkPath;
|
|
127
|
+
return findPageBySlug(slug, currentPath, nodes);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function KBEditor() {
|
|
131
|
+
const {
|
|
132
|
+
currentPath,
|
|
133
|
+
content,
|
|
134
|
+
saveStatus,
|
|
135
|
+
frontmatter,
|
|
136
|
+
isLoading,
|
|
137
|
+
loadStatus,
|
|
138
|
+
createMissingPage,
|
|
139
|
+
} = useEditorStore();
|
|
140
|
+
const nodes = useTreeStore((s) => s.nodes);
|
|
141
|
+
const isRtl = frontmatter?.dir === "rtl";
|
|
142
|
+
const { open: openAI, clearMessages } = useAIPanelStore();
|
|
143
|
+
const { open: openWikiCreate, Dialog: WikiCreateDialog } =
|
|
144
|
+
useWikiLinkCreate();
|
|
145
|
+
// Keep a stable ref so the click handler closure can call the latest version
|
|
146
|
+
// without being re-created on every render.
|
|
147
|
+
const openWikiCreateRef =
|
|
148
|
+
useRef<(slug: string) => Promise<WikiCreateResult>>(openWikiCreate);
|
|
149
|
+
openWikiCreateRef.current = openWikiCreate;
|
|
150
|
+
|
|
151
|
+
const isLoadingRef = useRef(false);
|
|
152
|
+
const [sourceMode, setSourceMode] = useState(false);
|
|
153
|
+
const [sourceText, setSourceText] = useState("");
|
|
154
|
+
// Reset the tab to "page" whenever the path changes — opening a new folder
|
|
155
|
+
// shouldn't skip its index.md if the previous folder was on Files. Has to
|
|
156
|
+
// be an effect (not state-during-render) because Tiptap's EditorContent
|
|
157
|
+
// calls flushSync internally; setState during the parent render explodes
|
|
158
|
+
// when EditorContent renders in the same pass.
|
|
159
|
+
const [folderTab, setFolderTab] = useState<"page" | "files">("page");
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
setFolderTab("page");
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// Prime the slug index once on mount so wiki-link broken-state and
|
|
165
|
+
// the autocomplete picker both have data immediately.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
void useWikiSlugsStore.getState().load();
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const handleUpdate = useCallback(
|
|
171
|
+
({ editor }: { editor: ReturnType<typeof useEditor> }) => {
|
|
172
|
+
if (isLoadingRef.current || !editor) return;
|
|
173
|
+
const html = editor.getHTML();
|
|
174
|
+
const md = htmlToMarkdown(html);
|
|
175
|
+
useEditorStore.getState().updateContent(md);
|
|
176
|
+
},
|
|
177
|
+
[],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const editor = useEditor({
|
|
181
|
+
extensions: editorExtensions,
|
|
182
|
+
content: "",
|
|
183
|
+
onUpdate: handleUpdate,
|
|
184
|
+
editorProps: {
|
|
185
|
+
attributes: {
|
|
186
|
+
class:
|
|
187
|
+
"focus:outline-none min-h-[calc(100vh-12rem)] px-4 sm:px-8 py-6 max-w-3xl mx-auto",
|
|
188
|
+
},
|
|
189
|
+
handleKeyDown: (view, event) => {
|
|
190
|
+
if (
|
|
191
|
+
(event.metaKey || event.ctrlKey) &&
|
|
192
|
+
event.key.toLowerCase() === "a" &&
|
|
193
|
+
isInTable(view.state)
|
|
194
|
+
) {
|
|
195
|
+
const $cell = cellAround(view.state.selection.$from);
|
|
196
|
+
const cell = $cell?.nodeAfter;
|
|
197
|
+
if (!$cell || !cell) return false;
|
|
198
|
+
|
|
199
|
+
const from = $cell.pos + 1;
|
|
200
|
+
const to = $cell.pos + cell.nodeSize - 1;
|
|
201
|
+
if (
|
|
202
|
+
view.state.selection.from === from &&
|
|
203
|
+
view.state.selection.to === to
|
|
204
|
+
) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
editor?.chain().focus().setTextSelection({ from, to }).run();
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return false;
|
|
214
|
+
},
|
|
215
|
+
handleClick: (_view, _pos, event) => {
|
|
216
|
+
const target = event.target as HTMLElement;
|
|
217
|
+
const link = target.closest("a") as HTMLAnchorElement | null;
|
|
218
|
+
if (!link) return false;
|
|
219
|
+
|
|
220
|
+
const href = link.getAttribute("href");
|
|
221
|
+
if (!href) return false;
|
|
222
|
+
|
|
223
|
+
// Wiki-links inserted by the WikiLink mark
|
|
224
|
+
if (link.dataset.wikiLink === "true") {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
event.stopPropagation();
|
|
227
|
+
const slug = link.dataset.slug ?? "";
|
|
228
|
+
const anchor = link.dataset.anchor ?? null;
|
|
229
|
+
if (!slug) return true;
|
|
230
|
+
|
|
231
|
+
const slugStore = useWikiSlugsStore.getState();
|
|
232
|
+
if (slugStore.has(slug)) {
|
|
233
|
+
const dir = slugStore.getDir(slug);
|
|
234
|
+
const pagePath =
|
|
235
|
+
dir === null || dir === "root"
|
|
236
|
+
? `${slug}.md`
|
|
237
|
+
: `${dir}/${slug}.md`;
|
|
238
|
+
void useEditorStore.getState().loadPage(pagePath);
|
|
239
|
+
if (anchor) {
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
document
|
|
242
|
+
.querySelector(`[id="${anchor}"]`)
|
|
243
|
+
?.scrollIntoView({ behavior: "smooth" });
|
|
244
|
+
}, 200);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
void openWikiCreateRef.current(slug).then((result) => {
|
|
248
|
+
if (result.ok) {
|
|
249
|
+
const dir = result.dir ?? "entities";
|
|
250
|
+
const pagePath = `${dir}/${result.slug}.md`;
|
|
251
|
+
void useEditorStore.getState().loadPage(pagePath);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Wiki-links: #page:slug
|
|
259
|
+
if (href.startsWith("#page:")) {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
event.stopPropagation();
|
|
262
|
+
const slug = href.replace("#page:", "");
|
|
263
|
+
const { nodes, selectPage, expandPath } = useTreeStore.getState();
|
|
264
|
+
const activePath = useEditorStore.getState().currentPath;
|
|
265
|
+
const targetPath = findPageBySlug(slug, activePath, nodes);
|
|
266
|
+
if (targetPath) {
|
|
267
|
+
navigateToPage(targetPath, selectPage, expandPath);
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Internal links: relative paths to .md files or other KB pages
|
|
273
|
+
// Skip external URLs and API asset links (PDFs, images)
|
|
274
|
+
if (/^https?:\/\//.test(href) || href.startsWith("/api/")) return false;
|
|
275
|
+
if (href.startsWith("mailto:") || href.startsWith("tel:")) return false;
|
|
276
|
+
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
event.stopPropagation();
|
|
279
|
+
|
|
280
|
+
const { nodes, selectPage, expandPath } = useTreeStore.getState();
|
|
281
|
+
const activePath = useEditorStore.getState().currentPath;
|
|
282
|
+
|
|
283
|
+
// Resolve the link target to a KB page path
|
|
284
|
+
const targetPath = resolveInternalLink(href, activePath, nodes);
|
|
285
|
+
if (targetPath) {
|
|
286
|
+
navigateToPage(targetPath, selectPage, expandPath);
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
},
|
|
290
|
+
handlePaste: (_view, event) => {
|
|
291
|
+
const files = event.clipboardData?.files;
|
|
292
|
+
const pagePath = useEditorStore.getState().currentPath;
|
|
293
|
+
|
|
294
|
+
// 1. File paste → upload then insert appropriate node
|
|
295
|
+
if (files && files.length > 0 && pagePath) {
|
|
296
|
+
for (const file of Array.from(files)) {
|
|
297
|
+
uploadFile(pagePath, file).then((url) => {
|
|
298
|
+
if (!url || !editor) return;
|
|
299
|
+
if (file.type.startsWith("image/")) {
|
|
300
|
+
editor
|
|
301
|
+
.chain()
|
|
302
|
+
.focus()
|
|
303
|
+
.setImage({ src: url, alt: file.name })
|
|
304
|
+
.run();
|
|
305
|
+
} else {
|
|
306
|
+
editor
|
|
307
|
+
.chain()
|
|
308
|
+
.focus()
|
|
309
|
+
.insertContent(`<a href="${url}">${file.name}</a>`)
|
|
310
|
+
.run();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return false;
|
|
318
|
+
},
|
|
319
|
+
handleDrop: (_view, event) => {
|
|
320
|
+
const files = event.dataTransfer?.files;
|
|
321
|
+
if (!files || files.length === 0) return false;
|
|
322
|
+
|
|
323
|
+
const pagePath = useEditorStore.getState().currentPath;
|
|
324
|
+
if (!pagePath) return false;
|
|
325
|
+
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
for (const file of Array.from(files)) {
|
|
328
|
+
uploadFile(pagePath, file).then((url) => {
|
|
329
|
+
if (!url || !editor) return;
|
|
330
|
+
if (file.type.startsWith("image/")) {
|
|
331
|
+
editor
|
|
332
|
+
.chain()
|
|
333
|
+
.focus()
|
|
334
|
+
.setImage({ src: url, alt: file.name })
|
|
335
|
+
.run();
|
|
336
|
+
} else {
|
|
337
|
+
editor
|
|
338
|
+
.chain()
|
|
339
|
+
.focus()
|
|
340
|
+
.insertContent(`<a href="${url}">${file.name}</a>`)
|
|
341
|
+
.run();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
immediatelyRender: false,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// When content updates from store (after loadPage), set it in editor
|
|
352
|
+
const prevPathRef = useRef<string | null>(null);
|
|
353
|
+
const renderedKeyRef = useRef<string | null>(null);
|
|
354
|
+
const [renderedPath, setRenderedPath] = useState<string | null>(null);
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
if (!editor || currentPath === null) return;
|
|
357
|
+
// Skip if content hasn't actually changed (same path, dirty edit)
|
|
358
|
+
if (
|
|
359
|
+
useEditorStore.getState().isDirty &&
|
|
360
|
+
currentPath === prevPathRef.current
|
|
361
|
+
)
|
|
362
|
+
return;
|
|
363
|
+
// During page navigation the store briefly holds content="" while the
|
|
364
|
+
// fetch is in flight. Rendering that empty string into ProseMirror is
|
|
365
|
+
// pure waste — every extension runs a full schema pass twice per
|
|
366
|
+
// navigation. Skip until the real content arrives.
|
|
367
|
+
if (isLoading && content === "") return;
|
|
368
|
+
// Dedupe identical (path, content) renders — e.g. cached paint followed
|
|
369
|
+
// by a fresh fetch that returned the same markdown.
|
|
370
|
+
const key = `${currentPath} ${content}`;
|
|
371
|
+
if (renderedKeyRef.current === key) {
|
|
372
|
+
if (renderedPath !== currentPath) setRenderedPath(currentPath);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
prevPathRef.current = currentPath;
|
|
376
|
+
|
|
377
|
+
const setContent = async () => {
|
|
378
|
+
isLoadingRef.current = true;
|
|
379
|
+
const html = await markdownToHtml(content, currentPath);
|
|
380
|
+
editor.commands.setContent(html);
|
|
381
|
+
renderedKeyRef.current = key;
|
|
382
|
+
setRenderedPath(currentPath);
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
isLoadingRef.current = false;
|
|
385
|
+
}, 50);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
setContent();
|
|
389
|
+
}, [editor, content, currentPath, isLoading, renderedPath]);
|
|
390
|
+
|
|
391
|
+
const showLoadingOverlay =
|
|
392
|
+
currentPath !== null && (isLoading || renderedPath !== currentPath);
|
|
393
|
+
|
|
394
|
+
const handleOpenAI = () => {
|
|
395
|
+
clearMessages();
|
|
396
|
+
openAI();
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
if (currentPath === null) {
|
|
400
|
+
return (
|
|
401
|
+
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
402
|
+
<div className="text-center space-y-3">
|
|
403
|
+
<p className="text-lg font-medium tracking-[-0.02em]">
|
|
404
|
+
No page selected
|
|
405
|
+
</p>
|
|
406
|
+
<p className="text-sm text-muted-foreground/70">
|
|
407
|
+
Select a page from the sidebar or create a new one
|
|
408
|
+
</p>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Path resolved to a folder (or otherwise-missing target) without an
|
|
415
|
+
// index.md. Render an explicit placeholder + Create CTA instead of
|
|
416
|
+
// dropping the user into an empty editor that pretends to be the page.
|
|
417
|
+
if (loadStatus === "missing") {
|
|
418
|
+
const slug = currentPath.split("/").pop() || currentPath;
|
|
419
|
+
const inferredTitle = slug
|
|
420
|
+
.replace(/[-_]+/g, " ")
|
|
421
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
422
|
+
const folderNode = findNodeByPath(nodes, currentPath);
|
|
423
|
+
const folderChildren = folderNode?.children ?? [];
|
|
424
|
+
const hasChildren = folderChildren.length > 0;
|
|
425
|
+
return (
|
|
426
|
+
<div className="flex-1 overflow-y-auto">
|
|
427
|
+
<div className="max-w-3xl mx-auto px-6 py-10 space-y-6">
|
|
428
|
+
<div className="space-y-3">
|
|
429
|
+
<p className="text-lg font-medium tracking-[-0.02em] text-foreground">
|
|
430
|
+
{inferredTitle}
|
|
431
|
+
</p>
|
|
432
|
+
<p className="text-sm text-muted-foreground/80">
|
|
433
|
+
This folder doesn't have an{" "}
|
|
434
|
+
<code className="px-1 py-0.5 rounded bg-muted text-[12px]">
|
|
435
|
+
index.md
|
|
436
|
+
</code>
|
|
437
|
+
{hasChildren ? " yet — its contents are listed below." : " yet."}
|
|
438
|
+
</p>
|
|
439
|
+
<button
|
|
440
|
+
onClick={() => void createMissingPage(inferredTitle)}
|
|
441
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-[13px] font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
442
|
+
>
|
|
443
|
+
<FilePlus className="h-3.5 w-3.5" />
|
|
444
|
+
Create page
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
{hasChildren && (
|
|
448
|
+
<FolderIndex
|
|
449
|
+
key={currentPath}
|
|
450
|
+
folderPath={currentPath}
|
|
451
|
+
entries={folderChildren}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const toggleSourceMode = async () => {
|
|
460
|
+
if (!sourceMode) {
|
|
461
|
+
// Switching TO source mode — grab current markdown
|
|
462
|
+
setSourceText(useEditorStore.getState().content);
|
|
463
|
+
setSourceMode(true);
|
|
464
|
+
} else {
|
|
465
|
+
// Switching FROM source mode — apply changes
|
|
466
|
+
useEditorStore.getState().updateContent(sourceText);
|
|
467
|
+
if (editor) {
|
|
468
|
+
isLoadingRef.current = true;
|
|
469
|
+
const html = await markdownToHtml(sourceText, currentPath ?? undefined);
|
|
470
|
+
editor.commands.setContent(html);
|
|
471
|
+
setTimeout(() => {
|
|
472
|
+
isLoadingRef.current = false;
|
|
473
|
+
}, 50);
|
|
474
|
+
}
|
|
475
|
+
setSourceMode(false);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Folder pages with both an index.md (loadStatus === "ok") AND children
|
|
480
|
+
// get a Page / Files tab strip so users can switch between the page body
|
|
481
|
+
// and the directory listing without leaving the route.
|
|
482
|
+
const renderedFolderNode = findNodeByPath(nodes, currentPath);
|
|
483
|
+
const renderedFolderChildren =
|
|
484
|
+
renderedFolderNode?.type === "directory" ||
|
|
485
|
+
renderedFolderNode?.type === "cabinet"
|
|
486
|
+
? (renderedFolderNode.children ?? [])
|
|
487
|
+
: [];
|
|
488
|
+
const showFolderTabs = renderedFolderChildren.length > 0;
|
|
489
|
+
const onFilesTab = showFolderTabs && folderTab === "files";
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<>
|
|
493
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
494
|
+
{showFolderTabs && (
|
|
495
|
+
<div className="flex items-center gap-1 px-3 pt-2 border-b border-border">
|
|
496
|
+
<button
|
|
497
|
+
onClick={() => setFolderTab("page")}
|
|
498
|
+
className={`px-3 py-1.5 text-[12px] rounded-t-md border-b-2 -mb-px transition-colors ${
|
|
499
|
+
folderTab === "page"
|
|
500
|
+
? "border-primary text-foreground"
|
|
501
|
+
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
502
|
+
}`}
|
|
503
|
+
aria-pressed={folderTab === "page"}
|
|
504
|
+
>
|
|
505
|
+
Page
|
|
506
|
+
</button>
|
|
507
|
+
<button
|
|
508
|
+
onClick={() => setFolderTab("files")}
|
|
509
|
+
className={`px-3 py-1.5 text-[12px] rounded-t-md border-b-2 -mb-px transition-colors ${
|
|
510
|
+
folderTab === "files"
|
|
511
|
+
? "border-primary text-foreground"
|
|
512
|
+
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
513
|
+
}`}
|
|
514
|
+
aria-pressed={folderTab === "files"}
|
|
515
|
+
>
|
|
516
|
+
Files
|
|
517
|
+
<span className="ml-1.5 text-muted-foreground/60">
|
|
518
|
+
{renderedFolderChildren.length}
|
|
519
|
+
</span>
|
|
520
|
+
</button>
|
|
521
|
+
</div>
|
|
522
|
+
)}
|
|
523
|
+
{onFilesTab ? (
|
|
524
|
+
<div className="flex-1 overflow-y-auto">
|
|
525
|
+
<div className="max-w-3xl mx-auto px-6 py-6">
|
|
526
|
+
<FolderIndex
|
|
527
|
+
key={currentPath}
|
|
528
|
+
folderPath={currentPath}
|
|
529
|
+
entries={renderedFolderChildren}
|
|
530
|
+
/>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
) : (
|
|
534
|
+
<>
|
|
535
|
+
<div className="flex items-center min-w-0">
|
|
536
|
+
<div className="flex-1 min-w-0">
|
|
537
|
+
{!sourceMode && <EditorToolbar editor={editor} />}
|
|
538
|
+
</div>
|
|
539
|
+
<button
|
|
540
|
+
onClick={toggleSourceMode}
|
|
541
|
+
className={`flex items-center gap-1.5 px-3 py-1 mr-2 text-[11px] rounded-md transition-colors border border-border ${
|
|
542
|
+
sourceMode
|
|
543
|
+
? "bg-primary text-primary-foreground"
|
|
544
|
+
: "text-muted-foreground hover:bg-accent"
|
|
545
|
+
}`}
|
|
546
|
+
>
|
|
547
|
+
<Code2 className="h-3 w-3" />
|
|
548
|
+
{sourceMode ? "Preview" : "Markdown"}
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
{sourceMode ? (
|
|
553
|
+
<div
|
|
554
|
+
className="flex-1 overflow-y-auto p-4"
|
|
555
|
+
dir={isRtl ? "rtl" : undefined}
|
|
556
|
+
>
|
|
557
|
+
<textarea
|
|
558
|
+
value={sourceText}
|
|
559
|
+
onChange={(e) => setSourceText(e.target.value)}
|
|
560
|
+
className="w-full h-full min-h-[calc(100vh-12rem)] bg-transparent font-mono text-[13px] leading-relaxed resize-none focus:outline-none"
|
|
561
|
+
spellCheck={false}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
) : (
|
|
565
|
+
<div className="flex-1 relative" dir={isRtl ? "rtl" : undefined}>
|
|
566
|
+
<div
|
|
567
|
+
className="absolute inset-0 overflow-y-auto"
|
|
568
|
+
data-editor-scroll
|
|
569
|
+
>
|
|
570
|
+
<EditorContent editor={editor} />
|
|
571
|
+
<EditorBubbleMenu editor={editor} />
|
|
572
|
+
<TableMenu editor={editor} />
|
|
573
|
+
<SlashCommands editor={editor} />
|
|
574
|
+
<WikiLinkPicker
|
|
575
|
+
editor={editor}
|
|
576
|
+
onCreateRequest={openWikiCreateRef.current}
|
|
577
|
+
/>
|
|
578
|
+
|
|
579
|
+
{/* AI Edit Prompt + slash hint */}
|
|
580
|
+
<div className="max-w-3xl mx-auto px-8 pb-8 flex items-center gap-4">
|
|
581
|
+
<button
|
|
582
|
+
onClick={handleOpenAI}
|
|
583
|
+
className="group flex items-center gap-2 text-[13px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
|
584
|
+
>
|
|
585
|
+
<Sparkles className="h-3.5 w-3.5 group-hover:text-primary transition-colors" />
|
|
586
|
+
<span>Edit with AI</span>
|
|
587
|
+
</button>
|
|
588
|
+
<span className="text-[11px] text-muted-foreground/30 select-none">
|
|
589
|
+
<kbd className="rounded px-1 py-0.5 font-mono text-[10px] ring-1 ring-foreground/10">
|
|
590
|
+
/
|
|
591
|
+
</kbd>{" "}
|
|
592
|
+
for commands
|
|
593
|
+
</span>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
{showLoadingOverlay && (
|
|
598
|
+
<div
|
|
599
|
+
className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-md z-20 pointer-events-none"
|
|
600
|
+
aria-hidden="true"
|
|
601
|
+
>
|
|
602
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground/70" />
|
|
603
|
+
</div>
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
|
|
608
|
+
{/* Status bar */}
|
|
609
|
+
<div className="flex items-center justify-between px-4 py-1 border-t border-border text-xs text-muted-foreground/60">
|
|
610
|
+
<span className="text-[10.5px] text-muted-foreground/30 select-none hidden sm:block">
|
|
611
|
+
<kbd className="rounded px-1 font-mono text-[9.5px] ring-1 ring-foreground/10">
|
|
612
|
+
⌘S
|
|
613
|
+
</kbd>{" "}
|
|
614
|
+
save
|
|
615
|
+
<span className="mx-1.5 opacity-40">·</span>
|
|
616
|
+
<kbd className="rounded px-1 font-mono text-[9.5px] ring-1 ring-foreground/10">
|
|
617
|
+
/
|
|
618
|
+
</kbd>{" "}
|
|
619
|
+
commands
|
|
620
|
+
</span>
|
|
621
|
+
<span>
|
|
622
|
+
{saveStatus === "saving" && "Saving..."}
|
|
623
|
+
{saveStatus === "saved" && "Saved"}
|
|
624
|
+
{saveStatus === "error" && "Save failed"}
|
|
625
|
+
</span>
|
|
626
|
+
</div>
|
|
627
|
+
</>
|
|
628
|
+
)}
|
|
629
|
+
</div>
|
|
630
|
+
{WikiCreateDialog}
|
|
631
|
+
</>
|
|
632
|
+
);
|
|
633
|
+
}
|