+
+
投稿目录
+
+ 选择稿件要放置的栏目,可在一级目录下新建二级栏目。
+
+
+
+
+
+ setSelectedKey((val as string) ?? "")}
+ showSearch
+ treeNodeFilterProp="label"
+ filterTreeNode={(input, node) =>
+ String(node.label ?? "")
+ .toLowerCase()
+ .includes(input.toLowerCase())
+ }
+ treeExpandedKeys={expandedKeys}
+ onTreeExpand={(keys) => setExpandedKeys(keys as string[])}
+ treeExpandAction="click"
+ placeholder="请选择(可搜索)"
+ allowClear
+ treeLine
+ listHeight={360}
+ popupMatchSelectWidth={false}
+ getPopupContainer={(trigger) =>
+ trigger?.parentElement ?? document.body
+ }
+ />
+
+
+ {needsSubdirName && (
+
+
+
setNewSub(e.target.value)}
+ />
+
+ 将创建路径:{selectedKey.split("/")[0]} /{" "}
+ {sanitizedSubdir || "<未填写>"}
+
+
+ )}
+
+
+ 路径预览:
+ {finalDirPath || "(未选择)"}
+
+
+ );
+}
diff --git a/app/components/EditorMetadataForm.tsx b/app/components/EditorMetadataForm.tsx
new file mode 100644
index 0000000..cc881a7
--- /dev/null
+++ b/app/components/EditorMetadataForm.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { useState } from "react";
+
+import { useEditorStore } from "@/lib/editor-store";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/app/components/ui/label";
+import { cn } from "@/lib/utils";
+
+/**
+ * 编辑器元数据表单组件
+ * 用于输入文章的标题、描述、标签和文件名
+ */
+export function EditorMetadataForm() {
+ const {
+ title,
+ description,
+ tags,
+ filename,
+ setTitle,
+ setDescription,
+ setTags,
+ setFilename,
+ } = useEditorStore();
+
+ const [tagsInputValue, setTagsInputValue] = useState(() => tags.join(", "));
+ const [skipNextSync, setSkipNextSync] = useState(false);
+ const [prevTags, setPrevTags] = useState(tags);
+
+ if (tags !== prevTags) {
+ setPrevTags(tags);
+ if (skipNextSync) {
+ setSkipNextSync(false);
+ } else {
+ setTagsInputValue(tags.join(", "));
+ }
+ }
+
+ // 处理标签输入(逗号分隔)
+ const handleTagsChange = (value: string) => {
+ setTagsInputValue(value);
+ const processedTags = value
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter((tag) => tag.length > 0);
+
+ setSkipNextSync(true);
+ setTags(processedTags);
+ };
+
+ // 处理标签输入框失去焦点 - 过滤所有空标签并同步展示值
+ const handleTagsBlur = () => {
+ const filteredTags = tags.filter((tag) => tag.length > 0);
+ setSkipNextSync(true);
+ setTags(filteredTags);
+ setTagsInputValue(filteredTags.join(", "));
+ };
+
+ // 自动添加 .md 后缀
+ const handleFilenameBlur = () => {
+ if (filename && !filename.endsWith(".md")) {
+ setFilename(filename + ".md");
+ }
+ };
+
+ return (
+
+
文章信息
+
+
+ {/* 标题 */}
+
+
+ setTitle(e.target.value)}
+ required
+ className={cn(!title && "aria-invalid:border-destructive")}
+ />
+
+
+ {/* 描述 */}
+
+
+ setDescription(e.target.value)}
+ />
+
+
+ {/* 标签 */}
+
+
+ handleTagsChange(e.target.value)}
+ onBlur={handleTagsBlur}
+ />
+
+
+ {/* 文件名 */}
+
+
+
setFilename(e.target.value)}
+ onBlur={handleFilenameBlur}
+ required
+ className={cn(!filename && "aria-invalid:border-destructive")}
+ />
+
自动添加 .md 后缀
+
+
+
+ {/* 预览标签 */}
+ {tags.length > 0 && (
+
+ {tags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/components/MarkdownEditor.tsx b/app/components/MarkdownEditor.tsx
new file mode 100644
index 0000000..7e901e7
--- /dev/null
+++ b/app/components/MarkdownEditor.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import {
+ forwardRef,
+ useRef,
+ useLayoutEffect,
+ useImperativeHandle,
+} from "react";
+import { Crepe } from "@milkdown/crepe";
+import type { ImageBlockFeatureConfig } from "@milkdown/crepe/feature/image-block";
+import {
+ upload,
+ uploadConfig,
+ type Uploader,
+} from "@milkdown/kit/plugin/upload";
+import type { Node as ProsemirrorNode } from "@milkdown/kit/prose/model";
+import { useImageBuffer } from "@/app/components/hooks/useImageBuffer";
+import { useEditorStore } from "@/lib/editor-store";
+
+// 导入 Crepe 主题样式
+import "@milkdown/crepe/theme/common/style.css";
+import "@milkdown/crepe/theme/frame.css";
+
+interface MarkdownEditorProps {
+ onImagesChange?: (count: number) => void;
+}
+
+export interface MarkdownEditorHandle {
+ getImages: () => Map