feat(assets): 素材库与画布 - 新增可交互通用文件夹
This commit is contained in:
208
web/src/components/canvas/canvas-folder-preview.tsx
Normal file
208
web/src/components/canvas/canvas-folder-preview.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Dropdown } from "antd";
|
||||
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
|
||||
|
||||
import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
const DEFAULT_FOLDER_COVER = "/images/canvas/folder-default-cover.png";
|
||||
|
||||
const FOLDER_STYLE_OPTIONS: Array<{ key: CanvasFolderStyle; label: string }> = [
|
||||
{ key: "glass", label: "流光玻璃" },
|
||||
{ key: "stacked", label: "内容陈列" },
|
||||
{ key: "midnight", label: "午夜封面" },
|
||||
{ key: "paper", label: "纸感收藏" },
|
||||
{ key: "cinema", label: "电影胶片" },
|
||||
{ key: "compact", label: "紧凑资料" },
|
||||
];
|
||||
|
||||
export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
|
||||
data,
|
||||
childNodes,
|
||||
active,
|
||||
isDropTarget,
|
||||
readOnly,
|
||||
onToggleCollapsed,
|
||||
onTitleChange,
|
||||
onStyleChange,
|
||||
}: {
|
||||
data: CanvasNodeData;
|
||||
childNodes: CanvasNodeData[];
|
||||
active: boolean;
|
||||
isDropTarget: boolean;
|
||||
readOnly: boolean;
|
||||
onToggleCollapsed: (nodeId: string) => void;
|
||||
onTitleChange: (nodeId: string, title: string) => void;
|
||||
onStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
|
||||
}) {
|
||||
const style = data.metadata?.folder?.style || "glass";
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState(data.title);
|
||||
const previewNodes = useMemo(() => childNodes.filter(canPreviewFolderNode).slice(0, 3), [childNodes]);
|
||||
const primaryNode = previewNodes[0];
|
||||
const showAdd = style === "glass" || childNodes.length === 0;
|
||||
|
||||
useEffect(() => setTitle(data.title), [data.title]);
|
||||
|
||||
const commitTitle = () => {
|
||||
const next = title.trim() || "未命名文件夹";
|
||||
setTitle(next);
|
||||
setEditing(false);
|
||||
onTitleChange(data.id, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`canvas-folder-preview canvas-folder-preview-${style}${active ? " is-active" : ""}${isDropTarget ? " is-drop-target" : ""}`}>
|
||||
<div className="canvas-folder-back" aria-hidden>
|
||||
<FolderNodeMedia node={primaryNode} fallback />
|
||||
</div>
|
||||
|
||||
<div className="canvas-folder-preview-stack" aria-hidden>
|
||||
{(previewNodes.length ? previewNodes : [undefined, undefined]).map((node, index) => (
|
||||
<div key={node?.id || `folder-placeholder-${index}`} className={`canvas-folder-preview-card canvas-folder-preview-card-${index + 1}`}>
|
||||
<FolderNodeMedia node={node} fallback={index === 0} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="canvas-folder-front">
|
||||
<div className="canvas-folder-front-media" aria-hidden>
|
||||
<FolderNodeMedia node={primaryNode} fallback />
|
||||
</div>
|
||||
<div className="canvas-folder-front-tint" aria-hidden />
|
||||
|
||||
<div className="canvas-folder-copy">
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="canvas-folder-title-input"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitTitle();
|
||||
if (event.key === "Escape") {
|
||||
setTitle(data.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-title"
|
||||
title={readOnly ? data.title : `${data.title} · 点击重命名`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!readOnly) setEditing(true);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!readOnly) setEditing(true);
|
||||
}}
|
||||
>
|
||||
<span>{data.title}</span>{!readOnly ? <Pencil aria-hidden /> : null}
|
||||
</button>
|
||||
)}
|
||||
<span className="canvas-folder-meta">
|
||||
{style === "midnight" ? formatFolderDate(data.metadata?.folder?.createdAt) : `${childNodes.length} 项内容`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!readOnly ? (
|
||||
showAdd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-action canvas-folder-add"
|
||||
aria-label="展开文件夹并添加内容"
|
||||
title="展开文件夹并添加内容"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleCollapsed(data.id);
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
) : (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectedKeys: [style],
|
||||
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
|
||||
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-action canvas-folder-options"
|
||||
aria-label={`文件夹选项,当前 ${childNodes.length} 项`}
|
||||
title="切换文件夹样式"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</Dropdown>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{!readOnly && showAdd ? (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectedKeys: [style],
|
||||
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
|
||||
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-style-trigger"
|
||||
aria-label="切换文件夹样式"
|
||||
title="切换文件夹样式"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
|
||||
{readOnly && childNodes.length > 0 ? <span className="canvas-folder-count">{childNodes.length}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function canPreviewFolderNode(node: CanvasNodeData) {
|
||||
return node.type !== CanvasNodeType.Frame;
|
||||
}
|
||||
|
||||
function FolderNodeMedia({ node, fallback = false }: { node?: CanvasNodeData; fallback?: boolean }) {
|
||||
if (node?.type === CanvasNodeType.Image && node.metadata?.content) {
|
||||
return <img src={node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
|
||||
}
|
||||
if (node?.type === CanvasNodeType.Video && node.metadata?.content) {
|
||||
return <video src={node.metadata.content} muted playsInline preload="metadata" />;
|
||||
}
|
||||
if (node?.type === CanvasNodeType.Drawing && (node.metadata?.drawingPreviewUrl || node.metadata?.content)) {
|
||||
return <img src={node.metadata.drawingPreviewUrl || node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
|
||||
}
|
||||
if (node?.type === CanvasNodeType.Video) return <Video className="canvas-folder-file-icon" />;
|
||||
if (node?.type === CanvasNodeType.Audio) return <FileAudio className="canvas-folder-file-icon" />;
|
||||
if (node?.type === CanvasNodeType.Skill) return <Sparkles className="canvas-folder-file-icon" />;
|
||||
if (node?.type === CanvasNodeType.Config) return <SlidersHorizontal className="canvas-folder-file-icon" />;
|
||||
if (node?.type === CanvasNodeType.Text || node?.type === CanvasNodeType.Script) {
|
||||
return <span className="canvas-folder-text-preview">{node.metadata?.content || node.title}</span>;
|
||||
}
|
||||
if (node) return <FileText className="canvas-folder-file-icon" />;
|
||||
return fallback ? <img src={DEFAULT_FOLDER_COVER} alt="" loading="lazy" decoding="async" draggable={false} /> : <span className="canvas-folder-file-sheet" />;
|
||||
}
|
||||
|
||||
function formatFolderDate(value?: string) {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
if (Number.isNaN(date.getTime())) return "刚刚创建";
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
Reference in New Issue
Block a user