refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试
This commit is contained in:
1 parent
e0a2697c60
commit
7f5d87ef1a
186 files changed
+33727
-29905
No files matched your search
@@ -0,0 +1,59 @@
|
||||
// 画布节点复制到系统剪贴板:图片统一转成 PNG 写入。
|
||||
//
|
||||
// 资源型图片必须经资源访问合同读取(本地/代理分发需要会话 Cookie,CDN 分发又必须不带 Cookie),
|
||||
// 因此只从资源缓存取 Blob,不回退到直接 fetch 源地址。
|
||||
|
||||
import { getCachedResourceBlob } from "@/services/resource-blob-cache";
|
||||
|
||||
export async function copyImageToSystemClipboard(source: string, storageKey?: string) {
|
||||
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
|
||||
const fetchPNG = async (): Promise<Blob> => {
|
||||
let sourceBlob: Blob | null = null;
|
||||
if (storageKey) {
|
||||
sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
|
||||
// A resource-backed image must be read through the resource access
|
||||
// contract. Do not fall back to fetch(source) here: local/proxy
|
||||
// deliveries may require the session cookie and a separate API
|
||||
// origin, while CDN deliveries must omit that cookie.
|
||||
if (!sourceBlob) throw new Error("图片资源读取失败");
|
||||
}
|
||||
if (!sourceBlob) {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
|
||||
sourceBlob = await response.blob();
|
||||
}
|
||||
return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
|
||||
};
|
||||
|
||||
// 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入,
|
||||
// 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。
|
||||
try {
|
||||
const item = new ClipboardItem({ "image/png": fetchPNG() });
|
||||
await navigator.clipboard.write([item]);
|
||||
return;
|
||||
} catch {
|
||||
// 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入
|
||||
}
|
||||
|
||||
const blob = await fetchPNG();
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
}
|
||||
|
||||
export async function convertClipboardImageToPNG(blob: Blob) {
|
||||
if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("当前浏览器无法处理这张图片");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png"));
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,18 @@ import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
|
||||
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
|
||||
import { canCancelGenerationTask } from "@/lib/generation-task-display";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router";
|
||||
import { loadAssetsForUse } from "@/services/user-data-sync";
|
||||
import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
|
||||
import { getCachedResourceBlob } from "@/services/resource-blob-cache";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { nanoid } from "nanoid";
|
||||
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
@@ -74,7 +72,7 @@ import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog";
|
||||
import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
|
||||
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
|
||||
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
|
||||
import { useFocusMode } from "@/hooks/use-focus-mode";
|
||||
@@ -92,7 +90,7 @@ import {
|
||||
replaceCanvasReferenceMentions,
|
||||
type CanvasResourceReference,
|
||||
} from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
|
||||
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
|
||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
@@ -105,7 +103,7 @@ import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvas
|
||||
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
|
||||
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { cancelGenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasSyncStatus } from "./canvas-sync-status";
|
||||
import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
|
||||
@@ -164,15 +162,14 @@ import {
|
||||
type StoryboardColumn,
|
||||
type StoryboardShotCount,
|
||||
type StoryboardShotDuration,
|
||||
type CanvasWorkflowKind,
|
||||
type CanvasWorkspaceMode,
|
||||
type CanvasToolMode,
|
||||
type ContextMenuState,
|
||||
type Position,
|
||||
type ViewportTransform,
|
||||
} from "@/types/canvas";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
|
||||
import { copyImageToSystemClipboard } from "./canvas-clipboard";
|
||||
|
||||
const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench })));
|
||||
const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal })));
|
||||
@@ -180,60 +177,6 @@ const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-d
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
|
||||
|
||||
|
||||
async function copyImageToSystemClipboard(source: string, storageKey?: string) {
|
||||
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
|
||||
const fetchPNG = async (): Promise<Blob> => {
|
||||
let sourceBlob: Blob | null = null;
|
||||
if (storageKey) {
|
||||
sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
|
||||
// A resource-backed image must be read through the resource access
|
||||
// contract. Do not fall back to fetch(source) here: local/proxy
|
||||
// deliveries may require the session cookie and a separate API
|
||||
// origin, while CDN deliveries must omit that cookie.
|
||||
if (!sourceBlob) throw new Error("图片资源读取失败");
|
||||
}
|
||||
if (!sourceBlob) {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
|
||||
sourceBlob = await response.blob();
|
||||
}
|
||||
return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
|
||||
};
|
||||
|
||||
// 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入,
|
||||
// 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。
|
||||
try {
|
||||
const item = new ClipboardItem({ "image/png": fetchPNG() });
|
||||
await navigator.clipboard.write([item]);
|
||||
return;
|
||||
} catch {
|
||||
// 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入
|
||||
}
|
||||
|
||||
const blob = await fetchPNG();
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
}
|
||||
|
||||
async function convertClipboardImageToPNG(blob: Blob) {
|
||||
if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("当前浏览器无法处理这张图片");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png"));
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
function visibleGenerationBatch(node: CanvasNodeData) {
|
||||
const batches = node.metadata?.generationBatches || [];
|
||||
for (let index = batches.length - 1; index >= 0; index -= 1) {
|
||||
@@ -268,9 +211,7 @@ function InfiniteCanvasPage() {
|
||||
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const assetHandoffRef = useRef("");
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const assetsHydrated = useAssetStore((state) => state.hydrated);
|
||||
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
|
||||
@@ -757,7 +698,6 @@ function InfiniteCanvasPage() {
|
||||
handleUploadRequest,
|
||||
imageInputRef,
|
||||
openAssetsAtPosition,
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
replaceNodeMedia,
|
||||
createFileNode,
|
||||
@@ -1104,7 +1044,6 @@ function InfiniteCanvasPage() {
|
||||
startBatchConnection,
|
||||
mouseWorld,
|
||||
pendingConnectionCreate,
|
||||
setConnecting,
|
||||
} = useCanvasConnectionController({
|
||||
projectId,
|
||||
config: effectiveConfig,
|
||||
@@ -1590,7 +1529,7 @@ function InfiniteCanvasPage() {
|
||||
}),
|
||||
[addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
|
||||
);
|
||||
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
|
||||
const { dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
|
||||
projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
projectTitle: currentProject?.title || "未命名画布",
|
||||
@@ -1612,7 +1551,7 @@ function InfiniteCanvasPage() {
|
||||
focusSelection: fitCanvasSelection,
|
||||
});
|
||||
|
||||
const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({
|
||||
const { selectCanvasStyle, styleApplying } = useCanvasStyleWorkflow({
|
||||
canvasId: projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
nodesRef,
|
||||
@@ -1730,12 +1669,6 @@ function InfiniteCanvasPage() {
|
||||
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
|
||||
});
|
||||
|
||||
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
|
||||
chatSessionsRef.current = sessions;
|
||||
activeChatIdRef.current = activeId;
|
||||
setChatSessions(sessions);
|
||||
setActiveChatId(activeId);
|
||||
}, []);
|
||||
|
||||
const startTitleEditing = useCallback(() => {
|
||||
setTitleDraft(currentProject?.title || "未命名画布");
|
||||
|
||||
Reference in new issue
Block a user