feat(assets): 文件夹主题 - 新增多主题皮肤并同步主线

This commit is contained in:
echoD886
2026-08-20 16:33:51 +08:00
93 changed files with 4081 additions and 1143 deletions

View File

@@ -3,7 +3,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, limitCanvasImageReferences, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -39,13 +39,18 @@ export async function executeImageGeneration({
showError,
registerPendingNodeIds,
}: CanvasGenerationExecution) {
const referenceLimitError = canvasImageReferenceLimitError(generationConfig, generationContext.referenceImages);
if (referenceLimitError) {
showError(referenceLimitError);
return;
}
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy"));
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
const referenceImages = limitCanvasImageReferences(generationConfig, generationContext.referenceImages);
const referenceImages = generationContext.referenceImages;
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages);
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text];

View File

@@ -7,7 +7,7 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
@@ -57,6 +57,7 @@ type CanvasProjectWorldLayersProps = {
onNodeResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onToggleFrame: (nodeId: string) => void;
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
onFolderThemeChange: (nodeId: string, theme: CanvasFolderTheme) => void;
onNodeTitleChange: (nodeId: string, title: string) => void;
onNodeContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
onNodeContentChange: (nodeId: string, content: string) => void;
@@ -123,6 +124,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
onResize={props.onNodeResize}
onToggleCollapsed={props.onToggleFrame}
onFolderStyleChange={props.onFolderStyleChange}
onFolderThemeChange={props.onFolderThemeChange}
onTitleChange={props.onNodeTitleChange}
onContextMenu={props.onNodeContextMenu}
/>

View File

@@ -110,6 +110,7 @@ import {
type CanvasAssistantSession,
type CanvasConnection,
type CanvasFolderStyle,
type CanvasFolderTheme,
type CanvasNodeData,
type CanvasMediaPerformanceMode,
type StoryboardColumn,
@@ -916,6 +917,7 @@ function InfiniteCanvasPage() {
downloadNodeImage,
handleConfigNodeChange,
handleFolderStyleChange,
handleFolderThemeChange,
handleFontSizeChange,
handleNodeContentChange,
handleNodePromptChange,
@@ -943,7 +945,8 @@ function InfiniteCanvasPage() {
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, createdAt: folder.createdAt });
const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, theme, createdAt: folder.createdAt });
}, [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition]);
const handleFrameToggle = useCallback((nodeId: string) => {
@@ -985,9 +988,10 @@ function InfiniteCanvasPage() {
const folder = folderId ? byId.get(folderId) : undefined;
if (!folder) return node;
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
if (node.title === folder.name && node.metadata?.folder?.style === style) return node;
const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
if (node.title === folder.name && node.metadata?.folder?.style === style && node.metadata?.folder?.theme === theme) return node;
changed = true;
return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style } } };
return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style, theme, themeCover: undefined } } };
});
return changed ? next : current;
});
@@ -1860,6 +1864,7 @@ function InfiniteCanvasPage() {
onNodeResize={handleNodeResize}
onToggleFrame={handleFrameToggle}
onFolderStyleChange={handleFolderStyleChange}
onFolderThemeChange={handleFolderThemeChange}
onNodeTitleChange={handleNodeTitleChange}
onNodeContextMenu={handleNodeContextMenu}
onNodeContentChange={handleNodeContentChange}

View File

@@ -13,7 +13,7 @@ import {
findRetrySourceNode,
generationReferenceUrls,
isGenerationCanceled,
limitCanvasImageReferences,
canvasImageReferenceLimitError,
resolveMetadataReferences,
resolveStoredReferenceImages,
runBackendCanvasGenerationTask,
@@ -108,7 +108,10 @@ export function useCanvasGenerationRetry({
try {
const promptOnly = retryMode === "video";
const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryContextPrompt, assets, promptOnly);
rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly);
rawContext =
hasSavedImageMetadata && !baseContext.characterReferences.length
? null
: await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly);
} catch (error) {
const failure = generationFailureMetadata(error, retryPromptSource);
message.error(failure.errorDetails);
@@ -168,7 +171,14 @@ export function useCanvasGenerationRetry({
message.error("参考图片已丢失,无法继续重试");
return;
}
const retryImages = retryMode === "image" ? limitCanvasImageReferences(generationConfig, retryReferenceImages || []) : retryReferenceImages || [];
const retryImages = retryReferenceImages || [];
if (retryMode === "image") {
const referenceLimitError = canvasImageReferenceLimitError(generationConfig, retryImages);
if (referenceLimitError) {
message.error(referenceLimitError);
return;
}
}
const storedVideoImages = node.type === CanvasNodeType.Video && !context?.referenceImages.length ? await resolveStoredReferenceImages(node.metadata?.references) : [];
if (storedVideoImages === null) {
markMissingReferences(node.id, setNodes);

View File

@@ -9,7 +9,7 @@ import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canva
import { audioExtension, imageExtension, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
type UseCanvasNodeEditorOptions = {
canvasId: string;
@@ -89,6 +89,14 @@ export function useCanvasNodeEditor({
}));
}, [setNodes]);
const handleFolderThemeChange = useCallback((nodeId: string, theme: CanvasFolderTheme) => {
setNodes((current) => current.map((node) => {
if (node.id !== nodeId || !isCanvasFolderNode(node)) return node;
const folder = node.metadata!.folder!;
return { ...node, metadata: { ...node.metadata, folder: { ...folder, theme, themeCover: undefined, createdAt: folder.createdAt || new Date().toISOString() } } };
}));
}, [setNodes]);
const toggleNodeFreeResize = useCallback((nodeId: string) => {
setNodes((current) =>
current.map((node) => {
@@ -190,6 +198,7 @@ export function useCanvasNodeEditor({
downloadNodeImage,
handleConfigNodeChange,
handleFolderStyleChange,
handleFolderThemeChange,
handleFontSizeChange,
handleNodeContentChange,
handleNodePromptChange,

View File

@@ -8,7 +8,7 @@ import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, g
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
@@ -133,7 +133,7 @@ export function useCanvasNodeOperations({
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; createdAt: string }) => {
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), {
frame: {
collapsed: true,
@@ -142,6 +142,7 @@ export function useCanvasNodeOperations({
},
folder: {
style: linked?.style || "glass",
theme: linked?.theme || "aurora",
createdAt: linked?.createdAt || new Date().toISOString(),
assetFolderId: linked?.id,
projectId: linked?.projectId,