feat(assets): 素材库与画布 - 新增可交互通用文件夹
This commit is contained in:
@@ -17,6 +17,13 @@ export type AssetLibraryPickerItem = {
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
disabledReason?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export type AssetLibraryPickerFolder = {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -24,6 +31,8 @@ type Props = {
|
||||
items: AssetLibraryPickerItem[];
|
||||
categoryLabels: Record<string, string>;
|
||||
initialCategory?: string;
|
||||
initialFolderId?: string;
|
||||
folders?: AssetLibraryPickerFolder[];
|
||||
initialSelectedIds?: Iterable<string>;
|
||||
multiple?: boolean;
|
||||
title?: string;
|
||||
@@ -32,6 +41,7 @@ type Props = {
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
footerNote?: string;
|
||||
folderActionLabel?: string;
|
||||
upload?: {
|
||||
accept: string;
|
||||
description: string;
|
||||
@@ -39,6 +49,7 @@ type Props = {
|
||||
};
|
||||
onClose: () => void;
|
||||
onConfirm: (ids: string[]) => Promise<void> | void;
|
||||
onFolderAction?: (folderId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AssetLibraryPickerModal({
|
||||
@@ -46,6 +57,8 @@ export function AssetLibraryPickerModal({
|
||||
items,
|
||||
categoryLabels,
|
||||
initialCategory = "all",
|
||||
initialFolderId = "all",
|
||||
folders = [],
|
||||
initialSelectedIds,
|
||||
multiple = true,
|
||||
title = "素材库",
|
||||
@@ -54,11 +67,14 @@ export function AssetLibraryPickerModal({
|
||||
emptyTitle = "这个分类还没有素材",
|
||||
emptyDescription = "换个分类后再试。",
|
||||
footerNote,
|
||||
folderActionLabel = "将文件夹放到画布",
|
||||
upload,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onFolderAction,
|
||||
}: Props) {
|
||||
const [category, setCategory] = useState(initialCategory);
|
||||
const [folderId, setFolderId] = useState(initialFolderId);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [working, setWorking] = useState(false);
|
||||
@@ -74,9 +90,10 @@ export function AssetLibraryPickerModal({
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (category !== "all" && item.category !== category) return false;
|
||||
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
|
||||
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
|
||||
});
|
||||
}, [category, items, keyword]);
|
||||
}, [category, folderId, items, keyword]);
|
||||
const selectedIds = useMemo(
|
||||
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
|
||||
[items, selected],
|
||||
@@ -85,13 +102,14 @@ export function AssetLibraryPickerModal({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCategory(initialCategory);
|
||||
setFolderId(initialFolderId);
|
||||
setKeyword("");
|
||||
const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id));
|
||||
setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id))));
|
||||
setWorking(false);
|
||||
setUploadingCount(0);
|
||||
setError("");
|
||||
}, [initialCategory, open]);
|
||||
}, [initialCategory, initialFolderId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (category === "all" || categories.includes(category)) return;
|
||||
@@ -140,6 +158,19 @@ export function AssetLibraryPickerModal({
|
||||
}
|
||||
};
|
||||
|
||||
const runFolderAction = async () => {
|
||||
if (!onFolderAction || folderId === "all" || working) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
await onFolderAction(folderId);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "文件夹操作失败,请重试");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
|
||||
const uploading = uploadingCount > 0;
|
||||
|
||||
@@ -166,6 +197,7 @@ export function AssetLibraryPickerModal({
|
||||
</header>
|
||||
<div className="asset-picker-body">
|
||||
<nav className="asset-picker-categories" aria-label="素材分类">
|
||||
{folders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={folderId === "all" ? "is-active" : ""} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span>全部文件夹</span><em>{items.length}</em></button>{renderPickerFolders(folders, items, folderId, setFolderId)}<span className="asset-picker-nav-label">分类</span></> : null}
|
||||
{categories.map((value) => (
|
||||
<button key={value} type="button" className={category === value ? "is-active" : ""} aria-pressed={category === value} onClick={() => setCategory(value)}>
|
||||
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
|
||||
@@ -194,6 +226,7 @@ export function AssetLibraryPickerModal({
|
||||
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
|
||||
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
|
||||
<div className="asset-picker-actions">
|
||||
{onFolderAction && folderId !== "all" ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
|
||||
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
|
||||
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
|
||||
{confirmLabel(selectedIds.length)}
|
||||
@@ -220,6 +253,21 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
);
|
||||
}
|
||||
|
||||
function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLibraryPickerItem[], selectedId: string, onSelect: (folderId: string) => void, parentId = "", depth = 0, visited: ReadonlySet<string> = new Set()): ReactNode {
|
||||
if (depth >= 8) return null;
|
||||
return folders.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id)).map((folder) => {
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return (
|
||||
<span key={folder.id} className="contents">
|
||||
<button type="button" className={selectedId === folder.id ? "is-active" : ""} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
|
||||
<span>{folder.name}</span><em>{items.filter((item) => item.folderId === folder.id).length}</em>
|
||||
</button>
|
||||
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function kindIcon(label: string): ReactNode {
|
||||
if (label.includes("角色")) return <UserRound />;
|
||||
if (label.includes("视频")) return <Video />;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
|
||||
import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
||||
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
@@ -33,6 +34,7 @@ type CanvasNodeContextMenuProps = {
|
||||
canPaste: boolean;
|
||||
onClose: () => void;
|
||||
onAddNode: (type: CanvasNodeType) => void;
|
||||
onAddFolder: () => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: (position: Position) => void;
|
||||
onUpload: () => void;
|
||||
@@ -65,6 +67,7 @@ export function CanvasNodeContextMenu({
|
||||
canPaste,
|
||||
onClose,
|
||||
onAddNode,
|
||||
onAddFolder,
|
||||
onChooseStyle,
|
||||
onOpenDirector,
|
||||
onUpload,
|
||||
@@ -128,6 +131,7 @@ export function CanvasNodeContextMenu({
|
||||
const isMedia = isImage || isVideo;
|
||||
const isAudio = node?.type === CanvasNodeType.Audio;
|
||||
const isFrame = node?.type === CanvasNodeType.Frame;
|
||||
const isFolder = isCanvasFolderNode(node);
|
||||
const hasNodeContent = isText ? Boolean(nodeContent.trim()) : Boolean(nodeContent);
|
||||
const canSaveAsset = Boolean(node && !isCharacterReference && (isText ? hasNodeContent : hasNodeContent && (isImage || isVideo || isAudio)));
|
||||
const canOpenPreview = Boolean(isMedia && hasNodeContent);
|
||||
@@ -207,17 +211,17 @@ export function CanvasNodeContextMenu({
|
||||
<>
|
||||
<MenuHeader title={node?.title || nodeTypeLabel(node)} />
|
||||
<MenuSection label="节点操作" />
|
||||
{isFrame ? <MenuButton icon={<PanelTop />} label={node?.metadata?.frame?.collapsed ? "展开背板" : "折叠背板"} onClick={() => runAction(onToggleFrame)} /> : <MenuButton icon={<FolderPlus />} label="保存到我的素材" disabled={!canSaveAsset} onClick={() => runAction(onSaveAsset)} />}
|
||||
{isFrame ? <MenuButton icon={isFolder ? <FolderOpen /> : <PanelTop />} label={node?.metadata?.frame?.collapsed ? `展开${isFolder ? "文件夹" : "背板"}` : `折叠${isFolder ? "文件夹" : "背板"}`} onClick={() => runAction(onToggleFrame)} /> : <MenuButton icon={<FolderPlus />} label="保存到我的素材" disabled={!canSaveAsset} onClick={() => runAction(onSaveAsset)} />}
|
||||
{isText ? <MenuButton icon={<Maximize2 />} label="放大编辑" onClick={() => runAction(onEditText)} /> : null}
|
||||
{isDrawing ? <MenuButton icon={<Pencil />} label="打开绘图" onClick={() => runAction(onOpenDrawing)} /> : null}
|
||||
{isText ? <MenuButton icon={<ImageIcon />} label="用文本生图" disabled={!canGenerateFromText} onClick={() => runAction(onGenerateImage)} /> : null}
|
||||
<MenuDivider />
|
||||
<MenuSection label="副本与内容" />
|
||||
<MenuButton icon={<Copy />} label={isFrame ? "复制背板及内容" : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
|
||||
<MenuButton icon={<Copy />} label={isFrame ? `复制${isFolder ? "文件夹" : "背板"}及内容` : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
|
||||
{isText ? <MenuButton icon={<Clipboard />} label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
|
||||
<MenuButton icon={<Copy />} label={isFrame ? "创建背板副本" : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
|
||||
<MenuButton icon={<Copy />} label={isFrame ? `创建${isFolder ? "文件夹" : "背板"}副本` : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
|
||||
<MenuButton icon={<Clipboard />} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
|
||||
<MenuButton icon={<Trash2 />} label={isFrame ? "删除背板" : "删除节点"} danger onClick={() => runAction(onDelete)} />
|
||||
<MenuButton icon={<Trash2 />} label={isFrame ? `删除${isFolder ? "文件夹" : "背板"}` : "删除节点"} danger onClick={() => runAction(onDelete)} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -238,6 +242,7 @@ export function CanvasNodeContextMenu({
|
||||
isProjectLinked={isProjectLinked}
|
||||
reducedMotion={Boolean(reducedMotion)}
|
||||
onAddNode={(type) => runAction(() => onAddNode(type))}
|
||||
onAddFolder={() => runAction(onAddFolder)}
|
||||
onChooseStyle={() => runAction(onChooseStyle)}
|
||||
onOpenDirector={() => runAction(() => onOpenDirector(menu.position))}
|
||||
onUpload={() => runAction(onUpload)}
|
||||
@@ -250,7 +255,7 @@ export function CanvasNodeContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const left = getSubmenuLeft(parentPosition.left);
|
||||
const createContext: AddNodeMenuContext = {
|
||||
@@ -263,6 +268,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
|
||||
onAddAudio: () => onAddNode(CanvasNodeType.Audio),
|
||||
onAddScript: () => onAddNode(CanvasNodeType.Script),
|
||||
onAddFrame: () => onAddNode(CanvasNodeType.Frame),
|
||||
onAddFolder,
|
||||
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
|
||||
onChooseStyle,
|
||||
onOpenDirector,
|
||||
@@ -364,6 +370,6 @@ function nodeTypeLabel(node?: CanvasNodeData | null) {
|
||||
if (node.type === CanvasNodeType.Video) return "视频节点";
|
||||
if (node.type === CanvasNodeType.Audio) return "音频节点";
|
||||
if (node.type === CanvasNodeType.Drawing) return "绘图节点";
|
||||
if (node.type === CanvasNodeType.Frame) return "背板";
|
||||
if (node.type === CanvasNodeType.Frame) return isCanvasFolderNode(node) ? "文件夹" : "背板";
|
||||
return "生成配置节点";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { ChevronDown, ChevronRight, Video } from "lucide-react";
|
||||
|
||||
import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
import { FRAME_HEADER_HEIGHT, FRAME_PADDING } from "@/lib/canvas/canvas-frame";
|
||||
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
@@ -20,6 +21,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
onMouseDown,
|
||||
onResize,
|
||||
onToggleCollapsed,
|
||||
onFolderStyleChange,
|
||||
onTitleChange,
|
||||
onContextMenu,
|
||||
readOnly = false,
|
||||
@@ -35,6 +37,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
onMouseDown: (event: ReactMouseEvent, nodeId: string) => void;
|
||||
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||||
onToggleCollapsed: (nodeId: string) => void;
|
||||
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
|
||||
onTitleChange: (nodeId: string, title: string) => void;
|
||||
onContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -43,6 +46,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const collapsed = Boolean(data.metadata?.frame?.collapsed);
|
||||
const folder = isCanvasFolderNode(data);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState(data.title);
|
||||
const resizeRef = useRef({
|
||||
@@ -63,7 +67,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
useEffect(() => setTitle(data.title), [data.title]);
|
||||
|
||||
const commitTitle = () => {
|
||||
const next = title.trim() || "未命名背板";
|
||||
const next = title.trim() || (folder ? "未命名文件夹" : "未命名背板");
|
||||
setTitle(next);
|
||||
setEditing(false);
|
||||
onTitleChange(data.id, next);
|
||||
@@ -140,11 +144,15 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
};
|
||||
|
||||
const active = isSelected || isDropTarget;
|
||||
const linkedFolder = Boolean(data.metadata?.folder?.assetFolderId);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-node-id={data.id}
|
||||
className={`absolute z-0 select-none ${dragOffset ? "cursor-grabbing" : "cursor-default"}`}
|
||||
role={folder && collapsed ? "group" : undefined}
|
||||
tabIndex={folder && collapsed ? 0 : undefined}
|
||||
aria-label={folder && collapsed ? `${data.title},文件夹,${childNodes.length} 项内容。按回车打开` : undefined}
|
||||
className={`absolute z-0 select-none${folder && collapsed ? " canvas-folder-node" : ""} ${dragOffset ? "cursor-grabbing" : "cursor-default"}`}
|
||||
style={{ transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`, width: data.width, height: data.height, contain: "layout style" }}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
@@ -153,22 +161,42 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
onToggleCollapsed(data.id);
|
||||
}}
|
||||
onContextMenu={(event) => onContextMenu(event, data.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (!folder || !collapsed || (event.target instanceof Element && event.target.closest("button,input"))) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onToggleCollapsed(data.id);
|
||||
}}
|
||||
onMouseEnter={() => onHoverStart?.(data.id)}
|
||||
onMouseLeave={() => onHoverEnd?.(data.id)}
|
||||
>
|
||||
{/* 帧节点同样禁用指针跟随 3D 位移,hover 使用 CSS 静态抬升 */}
|
||||
<CometCard
|
||||
containerClassName="h-full w-full"
|
||||
className="canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border"
|
||||
className={folder && collapsed ? "canvas-folder-shell overflow-visible" : `canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border${folder ? " canvas-folder-expanded" : ""}`}
|
||||
disabled
|
||||
data-canvas-frame-hover-locked={!collapsed || editing || Boolean(dragOffset) || scale < 0.32 ? "true" : "false"}
|
||||
style={{
|
||||
background: active ? theme.frame.activeFill : theme.frame.fill,
|
||||
borderColor: active ? theme.frame.activeStroke : theme.frame.stroke,
|
||||
borderWidth: 1 / Math.max(scale, 0.05),
|
||||
boxShadow: isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`,
|
||||
background: folder && collapsed ? "transparent" : active ? theme.frame.activeFill : theme.frame.fill,
|
||||
borderColor: folder && collapsed ? "transparent" : active ? theme.frame.activeStroke : theme.frame.stroke,
|
||||
borderWidth: folder && collapsed ? 0 : 1 / Math.max(scale, 0.05),
|
||||
boxShadow: folder && collapsed ? "none" : isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`,
|
||||
}}
|
||||
>
|
||||
{folder && collapsed ? (
|
||||
// 素材库目录是单一事实源;画布节点只负责浏览和调用,避免本地改名后被同步结果覆盖。
|
||||
<CanvasFolderPreview
|
||||
data={data}
|
||||
childNodes={childNodes}
|
||||
active={active}
|
||||
isDropTarget={isDropTarget}
|
||||
readOnly={readOnly || linkedFolder}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
onTitleChange={onTitleChange}
|
||||
onStyleChange={onFolderStyleChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="pointer-events-auto absolute inset-x-0 top-0 z-10 flex items-center gap-1.5 px-1.5" style={{ height: FRAME_HEADER_HEIGHT, color: theme.node.text }}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -220,6 +248,8 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
</div>
|
||||
|
||||
{collapsed ? <FramePreview nodes={childNodes} frame={data} theme={theme} /> : null}
|
||||
</>
|
||||
)}
|
||||
</CometCard>
|
||||
{!readOnly && !collapsed && isSelected && !data.metadata?.locked ? (
|
||||
<>
|
||||
|
||||
@@ -3,42 +3,46 @@ import { useMemo } from "react";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
import { resourceFileUrl } from "@/services/api/resources";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
|
||||
import { getRemoteAsset } from "@/services/api/user-data";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
|
||||
const categoryLabels: Record<string, string> = { all: "全部资产", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
|
||||
type ProjectPickerItem = { id: string; category: string; character?: ProjectAsset; media?: Asset };
|
||||
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
|
||||
|
||||
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", onClose, onInsert }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void }) {
|
||||
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
|
||||
const mediaAssets = useAssetStore((state) => state.assets);
|
||||
const items = useMemo<ProjectPickerItem[]>(() => {
|
||||
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
|
||||
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
|
||||
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", character: asset }];
|
||||
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }];
|
||||
const media = mediaById.get(asset.id);
|
||||
return media && media.kind !== "model" && media.kind !== "entity" ? [{ id: asset.id, category: asset.category || media.category || "other", media }] : [];
|
||||
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: asset.category || media?.category || "other", folderId: asset.folderId, project: asset, media }];
|
||||
});
|
||||
if (projectItems.length) return projectItems;
|
||||
// 自由画布(未关联短剧项目)或项目资产为空/后端未返回时,回退到本地素材库,避免弹窗空白
|
||||
if (detail) return projectItems;
|
||||
// 自由画布未关联项目时回退到个人素材库。
|
||||
return mediaAssets
|
||||
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
|
||||
.map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media }));
|
||||
}, [detail?.assets, mediaAssets]);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || media?.title || "未命名资产",
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
kindLabel: character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片",
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : undefined,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : undefined,
|
||||
searchText: media?.tags.join(" "),
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
|
||||
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}), [items]);
|
||||
|
||||
@@ -48,14 +52,21 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
items={pickerItems}
|
||||
categoryLabels={categoryLabels}
|
||||
initialCategory={initialCategory}
|
||||
initialFolderId={initialFolderId}
|
||||
folders={detail?.assetFolders || []}
|
||||
title="项目资产"
|
||||
confirmLabel={(count) => `引入已选资产${count ? `(${count})` : ""}`}
|
||||
emptyTitle="此分类没有可引用资产"
|
||||
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
|
||||
footerNote="角色引用会在生成时解析当前角色版本"
|
||||
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
|
||||
onClose={onClose}
|
||||
onConfirm={async (ids) => {
|
||||
const payloads = items.filter((item) => ids.includes(item.id)).map(toInsertPayload);
|
||||
const payloads = await Promise.all(items.filter((item) => ids.includes(item.id)).map(async (item) => {
|
||||
if (item.media || item.character || !item.project) return toInsertPayload(item);
|
||||
const { asset } = await getRemoteAsset(item.project.id);
|
||||
return toInsertPayload({ ...item, media: asset });
|
||||
}));
|
||||
if (!payloads.length) return;
|
||||
await onInsert(payloads);
|
||||
onClose();
|
||||
@@ -69,14 +80,29 @@ function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
|
||||
return projectCharacterToInsertPayload(item.character);
|
||||
}
|
||||
const asset = item.media;
|
||||
const project = item.project;
|
||||
if (!asset && project) {
|
||||
const resourceId = resourceIdFromStorageKey(project.storageKey);
|
||||
const remoteUrl = resourceId ? resourceFileUrl(resourceId) : "";
|
||||
if (project.mediaType === "text" && project.previewText) return { kind: "text", content: project.previewText, title: project.title, assetId: project.id };
|
||||
if (project.mediaType === "video" && remoteUrl) return { kind: "video", url: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id };
|
||||
if (project.mediaType === "audio" && remoteUrl) return { kind: "audio", url: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id };
|
||||
if (project.mediaType === "image" && remoteUrl) return { kind: "image", dataUrl: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id };
|
||||
throw new Error(`“${project.title}”缺少可读取的内容`);
|
||||
}
|
||||
if (!asset) throw new Error("项目资产不可用");
|
||||
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
|
||||
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "image") return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
|
||||
if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
|
||||
throw new Error("当前项目资产不能直接插入画布");
|
||||
}
|
||||
|
||||
function projectAssetMediaUrl(storageKey?: string, fallback = "") {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
return resourceId ? resourceFileUrl(resourceId) : fallback;
|
||||
}
|
||||
|
||||
export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAssetPayload {
|
||||
if (!asset.character) throw new Error("项目角色信息不完整");
|
||||
const card = asset.character;
|
||||
|
||||
@@ -32,6 +32,7 @@ export function CanvasToolbar({
|
||||
onChooseStyle,
|
||||
onAddScript,
|
||||
onAddFrame,
|
||||
onAddFolder,
|
||||
onAddDrawing,
|
||||
onOpenDirector,
|
||||
onUndo,
|
||||
@@ -61,6 +62,7 @@ export function CanvasToolbar({
|
||||
onChooseStyle: () => void;
|
||||
onAddScript: () => void;
|
||||
onAddFrame: () => void;
|
||||
onAddFolder: () => void;
|
||||
onAddDrawing: () => void;
|
||||
onOpenDirector: () => void;
|
||||
onUndo: () => void;
|
||||
@@ -122,6 +124,7 @@ export function CanvasToolbar({
|
||||
onAddAudio,
|
||||
onAddScript,
|
||||
onAddFrame,
|
||||
onAddFolder,
|
||||
onAddDrawing,
|
||||
onChooseStyle,
|
||||
onOpenDirector,
|
||||
|
||||
Reference in New Issue
Block a user