perf: optimize canvas image loading
This commit is contained in:
parent
314884dc77
commit
c7de64b3f6
@ -25,7 +25,9 @@ export default function CanvasRedirectPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/canvas", { cache: "no-store" });
|
||||
const response = await fetch("/api/canvas?limit=1&page=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const items = result?.data || [];
|
||||
|
||||
@ -19,9 +19,10 @@ export default function VideoCanvasRedirectPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/canvas?workspaceType=video", {
|
||||
cache: "no-store",
|
||||
});
|
||||
const response = await fetch(
|
||||
"/api/canvas?workspaceType=video&limit=1&page=1",
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const items = result?.data || [];
|
||||
|
||||
@ -3,7 +3,7 @@ import { auth } from '@/app/(auth)/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { canvasProject } from '@/lib/db/schema';
|
||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||
import { and, eq, desc } from 'drizzle-orm';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
|
||||
function resolveWorkspaceType(input: unknown) {
|
||||
return input === "agent" || input === "video" ? input : "canvas";
|
||||
@ -13,6 +13,14 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
|
||||
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
|
||||
}
|
||||
|
||||
function parsePositiveInteger(input: string | null, fallback: number) {
|
||||
const value = Number(input);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
// 获取画布列表
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@ -24,9 +32,30 @@ export async function GET(request: NextRequest) {
|
||||
const workspaceType = resolveWorkspaceType(
|
||||
request.nextUrl.searchParams.get("workspaceType")
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
parsePositiveInteger(request.nextUrl.searchParams.get("limit"), 24),
|
||||
1
|
||||
),
|
||||
50
|
||||
);
|
||||
const page = Math.max(
|
||||
parsePositiveInteger(request.nextUrl.searchParams.get("page"), 1),
|
||||
1
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const projects = await db
|
||||
.select()
|
||||
.select({
|
||||
id: canvasProject.id,
|
||||
userId: canvasProject.userId,
|
||||
title: canvasProject.title,
|
||||
description: canvasProject.description,
|
||||
thumbnail: canvasProject.thumbnail,
|
||||
workspaceType: canvasProject.workspaceType,
|
||||
createdAt: canvasProject.createdAt,
|
||||
updatedAt: canvasProject.updatedAt,
|
||||
})
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
@ -34,9 +63,21 @@ export async function GET(request: NextRequest) {
|
||||
eq(canvasProject.workspaceType, workspaceType)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt));
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(limit + 1)
|
||||
.offset(offset);
|
||||
|
||||
return NextResponse.json({ success: true, data: projects });
|
||||
const hasMore = projects.length > limit;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: hasMore ? projects.slice(0, limit) : projects,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
hasMore,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取画布列表失败:', error);
|
||||
return NextResponse.json({ success: false, error: '获取画布列表失败' }, { status: 500 });
|
||||
|
||||
@ -62,35 +62,15 @@ import {
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
|
||||
import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
||||
import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew";
|
||||
// 深色主题组件 V2 (新版布局)
|
||||
import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2";
|
||||
import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew";
|
||||
import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet";
|
||||
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
|
||||
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
|
||||
import {
|
||||
AgentWorkflowBrandStorePanel,
|
||||
AgentWorkflowPanelDarkV2,
|
||||
} from "@/components/agents/AgentWorkflowPanelDarkV2";
|
||||
import {
|
||||
type AgentWorkflowInputs,
|
||||
AgentWorkflowPanelNew,
|
||||
} from "@/components/agents/AgentWorkflowPanelNew";
|
||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||||
import { type AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanelNew";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
|
||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -250,19 +230,163 @@ import type {
|
||||
WorkflowNodeData,
|
||||
} from "@/types/canvas";
|
||||
|
||||
function CanvasLazyPanelLoader({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center bg-background/80 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CanvasWorkspace = dynamic(
|
||||
() => import("@/components/canvas").then((module) => module.CanvasWorkspace),
|
||||
() =>
|
||||
import("@/components/canvas/CanvasWorkspace").then(
|
||||
(module) => module.CanvasWorkspace
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载画布..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const CanvasChatPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/canvas/CanvasChatPanel").then(
|
||||
(module) => module.CanvasChatPanel
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载对话面板..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const CanvasFileListPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/canvas/CanvasFileListPanel").then(
|
||||
(module) => module.CanvasFileListPanel
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载项目列表..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const SceneFusionDialog = dynamic(
|
||||
() =>
|
||||
import("@/components/scene-fusion-dialog").then(
|
||||
(module) => module.SceneFusionDialog
|
||||
),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentCanvasTopNavDark = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentCanvasTopNavDark").then(
|
||||
(module) => module.AgentCanvasTopNavDark
|
||||
),
|
||||
{
|
||||
loading: () => <div className="h-20 bg-[#050607]" />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentGenerationWorkspaceDarkV2 = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentGenerationWorkspaceDarkV2").then(
|
||||
(module) => module.AgentGenerationWorkspaceDarkV2
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
正在加载画布...
|
||||
</div>
|
||||
<CanvasLazyPanelLoader
|
||||
className="min-h-dvh bg-[#050607] text-white/65"
|
||||
label="正在加载风格化工作台..."
|
||||
/>
|
||||
),
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentPresetPanelDarkV2 = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentPresetPanelDarkV2").then(
|
||||
(module) => module.AgentPresetPanelDarkV2
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载风格化模板..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentProjectListSheet = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentProjectListSheet").then(
|
||||
(module) => module.AgentProjectListSheet
|
||||
),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentWorkflowBrandModelPicker = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentWorkflowBrandModelPicker").then(
|
||||
(module) => module.AgentWorkflowBrandModelPicker
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载品牌模特..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentWorkflowMaterialSelectionPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentWorkflowMaterialSelectionPanel").then(
|
||||
(module) => module.AgentWorkflowMaterialSelectionPanel
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载素材选择..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentWorkflowBrandStorePanel = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentWorkflowPanelDarkV2").then(
|
||||
(module) => module.AgentWorkflowBrandStorePanel
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载品牌配置..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AgentWorkflowPanelDarkV2 = dynamic(
|
||||
() =>
|
||||
import("@/components/agents/AgentWorkflowPanelDarkV2").then(
|
||||
(module) => module.AgentWorkflowPanelDarkV2
|
||||
),
|
||||
{
|
||||
loading: () => <CanvasLazyPanelLoader label="正在加载风格化配置..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
@ -502,7 +626,9 @@ function normalizeRealtimeCanvasModeDisplayConfig(
|
||||
: {};
|
||||
const normalizeUrl = (input: unknown) => String(input || "").trim();
|
||||
const normalizeLabel = (input: unknown, fallback: string) => {
|
||||
const label = String(input || "").trim().slice(0, 32);
|
||||
const label = String(input || "")
|
||||
.trim()
|
||||
.slice(0, 32);
|
||||
return label || fallback;
|
||||
};
|
||||
|
||||
@ -1144,7 +1270,10 @@ function VideoCanvasMarkerPanel({
|
||||
>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: entry.soft, color: entry.color }}
|
||||
style={{
|
||||
backgroundColor: entry.soft,
|
||||
color: entry.color,
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
@ -1421,7 +1550,11 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
const routeParams = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = routeParams?.id;
|
||||
const id = Array.isArray(routeId) ? routeId[0] || "" : typeof routeId === "string" ? routeId : "";
|
||||
const id = Array.isArray(routeId)
|
||||
? routeId[0] || ""
|
||||
: typeof routeId === "string"
|
||||
? routeId
|
||||
: "";
|
||||
const router = useRouter();
|
||||
const mode = searchParams.get("mode");
|
||||
const requestedWorkspaceType = searchParams.get("workspaceType");
|
||||
@ -1488,6 +1621,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||
useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
|
||||
useState(false);
|
||||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
||||
useState(false);
|
||||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
||||
@ -1783,9 +1918,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
[selectedVideoWorkflowType]
|
||||
);
|
||||
const selectedVideoCanvasMarkers = useMemo(() => {
|
||||
const metadata = (selectedVideoWorkflowNode?.data as
|
||||
| { metadata?: unknown }
|
||||
| undefined)?.metadata;
|
||||
const metadata = (
|
||||
selectedVideoWorkflowNode?.data as { metadata?: unknown } | undefined
|
||||
)?.metadata;
|
||||
return normalizeVideoCanvasMarkers(
|
||||
isPlainObject(metadata)
|
||||
? metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||
@ -1846,11 +1981,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
selectedWorkflowQuickConnectAnchor && selectedVideoWorkflowNode
|
||||
? selectedVideoWorkflowNode
|
||||
: null;
|
||||
const videoModuleSidebarWidth = isVideoMode && isVideoModuleSidebarEnabled
|
||||
? isVideoModuleSidebarCollapsed
|
||||
? 58
|
||||
: 236
|
||||
: 0;
|
||||
const videoModuleSidebarWidth =
|
||||
isVideoMode && isVideoModuleSidebarEnabled
|
||||
? isVideoModuleSidebarCollapsed
|
||||
? 58
|
||||
: 236
|
||||
: 0;
|
||||
const selectedWorkflowPanelLeft = isVideoMode
|
||||
? videoModuleSidebarWidth + 24
|
||||
: 16;
|
||||
@ -2613,8 +2749,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
};
|
||||
}, [checkCanvasAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setAreSecondaryPanelsReady(true);
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// 加载画布数据
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
console.log("[Canvas] Loading project:", id);
|
||||
|
||||
const loadProject = async () => {
|
||||
@ -5935,15 +6078,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
definition.size.height / 2,
|
||||
}
|
||||
: definition
|
||||
? {
|
||||
x:
|
||||
videoCanvasInsertMenu.canvasPosition.x -
|
||||
Math.min(Math.round(definition.size.width * 0.18), 84),
|
||||
y:
|
||||
videoCanvasInsertMenu.canvasPosition.y -
|
||||
Math.min(Math.round(definition.size.height * 0.12), 42),
|
||||
}
|
||||
: videoCanvasInsertMenu.canvasPosition;
|
||||
? {
|
||||
x:
|
||||
videoCanvasInsertMenu.canvasPosition.x -
|
||||
Math.min(Math.round(definition.size.width * 0.18), 84),
|
||||
y:
|
||||
videoCanvasInsertMenu.canvasPosition.y -
|
||||
Math.min(Math.round(definition.size.height * 0.12), 42),
|
||||
}
|
||||
: videoCanvasInsertMenu.canvasPosition;
|
||||
|
||||
handleAddWorkflowNode(templateType, {
|
||||
position: anchoredPosition,
|
||||
@ -8276,7 +8419,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
success?: boolean;
|
||||
data?: AgentPreset;
|
||||
}>(
|
||||
effectiveSelectedAgentPresetId ? `/api/agents/${effectiveSelectedAgentPresetId}` : null,
|
||||
effectiveSelectedAgentPresetId
|
||||
? `/api/agents/${effectiveSelectedAgentPresetId}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
const selectedAgentPresetData = useMemo(
|
||||
@ -8351,17 +8496,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
? String(agentProjectTitle || "").trim() || "未命名生图项目"
|
||||
: "未选择项目";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground text-sm">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const brandEntry = (
|
||||
<div
|
||||
className="absolute top-4 left-4 z-20 rounded-full px-1 py-1 shadow-none"
|
||||
@ -8519,15 +8653,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AgentProjectListSheet
|
||||
currentProjectId={agentProjectId || null}
|
||||
onCreateProject={handleCreateAgentProject}
|
||||
onDeleteProject={handleDeleteAgentProject}
|
||||
onOpenChange={setIsAgentProjectSheetOpen}
|
||||
onRenameProject={handleRenameAgentProject}
|
||||
onSelectProject={handleSelectAgentProject}
|
||||
open={isAgentProjectSheetOpen}
|
||||
/>
|
||||
{isAgentProjectSheetOpen ? (
|
||||
<AgentProjectListSheet
|
||||
currentProjectId={agentProjectId || null}
|
||||
onCreateProject={handleCreateAgentProject}
|
||||
onDeleteProject={handleDeleteAgentProject}
|
||||
onOpenChange={setIsAgentProjectSheetOpen}
|
||||
onRenameProject={handleRenameAgentProject}
|
||||
onSelectProject={handleSelectAgentProject}
|
||||
open={isAgentProjectSheetOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@ -8555,6 +8691,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
const projectLoadingBadge = isLoading ? (
|
||||
<div className="pointer-events-none fixed top-4 right-4 z-[90] inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/90 px-3.5 py-2 text-muted-foreground text-xs shadow-lg backdrop-blur">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在同步项目数据
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const agentProjectSelectionScreen =
|
||||
isAgentMode && !hasActiveAgentProject ? (
|
||||
@ -8623,6 +8765,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<>
|
||||
{agentProjectSelectionScreen}
|
||||
{loginDialogElement}
|
||||
{projectLoadingBadge}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -8728,6 +8871,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
workspaceState={agentWorkspaceState}
|
||||
/>
|
||||
{loginDialogElement}
|
||||
{projectLoadingBadge}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -8916,7 +9060,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-[12px] text-[#7a766f] hover:bg-[#f6f5f4] hover:text-[#16181d] dark:hover:bg-zinc-900",
|
||||
isVideoModuleSidebarCollapsed && "absolute top-2.5 right-2.5"
|
||||
isVideoModuleSidebarCollapsed &&
|
||||
"absolute top-2.5 right-2.5"
|
||||
)}
|
||||
onClick={() =>
|
||||
setIsVideoModuleSidebarCollapsed((prev) => !prev)
|
||||
@ -9154,22 +9299,27 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
const position = definition
|
||||
? {
|
||||
x: selectedWorkflowQuickConnectAnchor.openLeft
|
||||
? selectedWorkflowQuickConnectSourceNode.position.x -
|
||||
? selectedWorkflowQuickConnectSourceNode.position
|
||||
.x -
|
||||
definition.size.width -
|
||||
180
|
||||
: selectedWorkflowQuickConnectSourceNode.position.x +
|
||||
selectedWorkflowQuickConnectSourceNode.size.width +
|
||||
: selectedWorkflowQuickConnectSourceNode.position
|
||||
.x +
|
||||
selectedWorkflowQuickConnectSourceNode.size
|
||||
.width +
|
||||
180,
|
||||
y:
|
||||
selectedWorkflowQuickConnectSourceNode.position.y +
|
||||
selectedWorkflowQuickConnectSourceNode.size.height / 2 -
|
||||
selectedWorkflowQuickConnectSourceNode.size.height /
|
||||
2 -
|
||||
definition.size.height / 2 +
|
||||
y / viewport.zoom,
|
||||
}
|
||||
: null;
|
||||
handleAddWorkflowNode(item.type, {
|
||||
connectFromSelection: true,
|
||||
connectFromNodeId: selectedWorkflowQuickConnectSourceNode.id,
|
||||
connectFromNodeId:
|
||||
selectedWorkflowQuickConnectSourceNode.id,
|
||||
...(position ? { position } : {}),
|
||||
});
|
||||
}}
|
||||
@ -9475,7 +9625,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||
{panelMode === "chat" ? (
|
||||
{!areSecondaryPanelsReady ? (
|
||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||
) : panelMode === "chat" ? (
|
||||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||||
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
|
||||
<CanvasChatPanel
|
||||
@ -9585,12 +9737,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
active={tool === "select"}
|
||||
className="h-4 w-4"
|
||||
fallback={MousePointer}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "selectTool")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"selectTool"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label} (V)
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label}{" "}
|
||||
(V)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -9639,13 +9795,19 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Save}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"saveCanvas"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas").label}
|
||||
{
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas")
|
||||
.label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -9665,12 +9827,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={FolderOpen}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"cloudFiles"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles").label}
|
||||
{
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles")
|
||||
.label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -9695,12 +9863,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
active={isVideoCanvasMarkerPanelOpen}
|
||||
className="h-4 w-4"
|
||||
fallback={Tag}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "markerPanel")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"markerPanel"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "markerPanel").label}
|
||||
{
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "markerPanel")
|
||||
.label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -9726,12 +9900,21 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
active={tool === "draw"}
|
||||
className="h-4 w-4"
|
||||
fallback={GitBranch}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"workflowConnection"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection").label} (L)
|
||||
{
|
||||
getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"workflowConnection"
|
||||
).label
|
||||
}{" "}
|
||||
(L)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -9757,7 +9940,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
active={isAgentMode}
|
||||
className="h-4 w-4"
|
||||
fallback={Wand2}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "styleMode")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"styleMode"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@ -9783,12 +9969,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Layers}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"batchProcessing"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing").label}
|
||||
{
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing")
|
||||
.label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -9869,12 +10061,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Video}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"addVideoInput"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput").label}
|
||||
{
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput")
|
||||
.label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -9886,14 +10084,20 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||||
size="icon"
|
||||
title={
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary").label
|
||||
getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"workflowNodeLibrary"
|
||||
).label
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Grid3X3}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"workflowNodeLibrary"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@ -9977,11 +10181,11 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
onClick={() => router.push(`/canvas/${id}/realtime`)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
)}
|
||||
onClick={() => router.push(`/canvas/${id}/realtime`)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={PenTool}
|
||||
@ -10022,13 +10226,13 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
onClick={() =>
|
||||
router.push(`/canvas/${id}/realtime?mode=enhance-pro`)
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
)}
|
||||
onClick={() =>
|
||||
router.push(`/canvas/${id}/realtime?mode=enhance-pro`)
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
@ -10062,8 +10266,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
{
|
||||
iconUrl:
|
||||
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
|
||||
label:
|
||||
realtimeCanvasModeDisplayConfig.enhanceLabel,
|
||||
label: realtimeCanvasModeDisplayConfig.enhanceLabel,
|
||||
}
|
||||
).label
|
||||
}后上传图片`}
|
||||
@ -10107,12 +10310,20 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Download}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"exportVideoCanvas"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas").label}
|
||||
{
|
||||
getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"exportVideoCanvas"
|
||||
).label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -10133,13 +10344,21 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Upload}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas")}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"importVideoCanvas"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas").label}
|
||||
{
|
||||
getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"importVideoCanvas"
|
||||
).label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@ -10268,7 +10487,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</Button>
|
||||
<span
|
||||
className="w-10 text-center font-medium text-[10px] text-muted-foreground tabular-nums"
|
||||
title={getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label}
|
||||
title={
|
||||
getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label
|
||||
}
|
||||
>
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</span>
|
||||
@ -10291,9 +10512,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7. 物品拾取模式覆盖层 */}
|
||||
<ObjectPickerOverlay />
|
||||
{isBatchProcessingEnabled ? (
|
||||
{isBatchProcessingEnabled && sceneFusionDialogOpen ? (
|
||||
<SceneFusionDialog
|
||||
onBatchAddToCanvas={handleBatchAddToCanvas}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
@ -10301,6 +10520,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
/>
|
||||
) : null}
|
||||
{loginDialogElement}
|
||||
{projectLoadingBadge}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@ -17,8 +17,9 @@ import {
|
||||
import { canvasService } from "@/services/canvas";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import type { CanvasProject } from "@/types/canvas";
|
||||
import type { CanvasProjectListItem } from "@/types/canvas";
|
||||
|
||||
const PROJECT_LIST_PAGE_SIZE = 24;
|
||||
|
||||
interface CanvasFileListProps {
|
||||
open: boolean;
|
||||
@ -28,26 +29,52 @@ interface CanvasFileListProps {
|
||||
|
||||
export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileListProps) {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<CanvasProject[]>([]);
|
||||
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
// 加载项目列表
|
||||
const loadProjects = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await canvasService.getProjects();
|
||||
const loadProjects = async (nextPage = 1, append = false) => {
|
||||
if (append) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const result = await canvasService.getProjects("canvas", {
|
||||
page: nextPage,
|
||||
limit: PROJECT_LIST_PAGE_SIZE,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
// 按更新时间降序排序
|
||||
const sorted = result.data.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
setProjects(sorted);
|
||||
setProjects((prev) => {
|
||||
if (!append) return sorted;
|
||||
const seen = new Set(prev.map((project) => project.id));
|
||||
return [
|
||||
...prev,
|
||||
...sorted.filter((project) => !seen.has(project.id)),
|
||||
];
|
||||
});
|
||||
setPage(nextPage);
|
||||
setHasMore(Boolean(result.pagination?.hasMore));
|
||||
}
|
||||
if (append) {
|
||||
setIsLoadingMore(false);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadProjects();
|
||||
setProjects([]);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
loadProjects(1, false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@ -136,30 +163,9 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
: "bg-card border-transparent"
|
||||
)}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="h-12 w-12 rounded-lg bg-muted/50 overflow-hidden shrink-0 border">
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={buildImageProxyUrl(project.thumbnail, {
|
||||
width: 144,
|
||||
quality: 82,
|
||||
format: "webp",
|
||||
})}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== project.thumbnail) {
|
||||
event.currentTarget.src = project.thumbnail || "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<FileImage className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
|
||||
<FileImage className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
@ -190,9 +196,24 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-center py-4 text-xs text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => loadProjects(page + 1, true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@ -10,8 +10,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { canvasService } from "@/services/canvas";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import type { CanvasProject, CanvasWorkspaceType } from "@/types/canvas";
|
||||
import type { CanvasProjectListItem, CanvasWorkspaceType } from "@/types/canvas";
|
||||
|
||||
const PROJECT_LIST_PAGE_SIZE = 24;
|
||||
|
||||
interface CanvasFileListPanelProps {
|
||||
currentId: string;
|
||||
@ -32,25 +33,51 @@ export function CanvasFileListPanel({
|
||||
}: CanvasFileListPanelProps) {
|
||||
const router = useRouter();
|
||||
const isVideoWorkspace = workspaceType === "video";
|
||||
const [projects, setProjects] = useState<CanvasProject[]>([]);
|
||||
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
// 加载项目列表
|
||||
const loadProjects = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await canvasService.getProjects(workspaceType);
|
||||
const loadProjects = async (nextPage = 1, append = false) => {
|
||||
if (append) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const result = await canvasService.getProjects(workspaceType, {
|
||||
page: nextPage,
|
||||
limit: PROJECT_LIST_PAGE_SIZE,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
// 按更新时间降序排序
|
||||
const sorted = result.data.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
setProjects(sorted);
|
||||
setProjects((prev) => {
|
||||
if (!append) return sorted;
|
||||
const seen = new Set(prev.map((project) => project.id));
|
||||
return [
|
||||
...prev,
|
||||
...sorted.filter((project) => !seen.has(project.id)),
|
||||
];
|
||||
});
|
||||
setPage(nextPage);
|
||||
setHasMore(Boolean(result.pagination?.hasMore));
|
||||
}
|
||||
if (append) {
|
||||
setIsLoadingMore(false);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
setProjects([]);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
loadProjects(1, false);
|
||||
}, [workspaceType]);
|
||||
|
||||
// 创建新画布
|
||||
@ -163,37 +190,16 @@ export function CanvasFileListPanel({
|
||||
: "bg-card"
|
||||
)}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
|
||||
<div
|
||||
className={cn(
|
||||
"h-14 w-14 shrink-0 overflow-hidden border",
|
||||
"flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden border",
|
||||
isVideoWorkspace
|
||||
? "rounded-xl border-border/60 bg-background/80"
|
||||
: "rounded-lg bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={buildImageProxyUrl(project.thumbnail, {
|
||||
width: 160,
|
||||
quality: 82,
|
||||
format: "webp",
|
||||
})}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== project.thumbnail) {
|
||||
event.currentTarget.src = project.thumbnail || "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<FileImage className="h-6 w-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
<FileImage className="h-6 w-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
@ -229,9 +235,24 @@ export function CanvasFileListPanel({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-center py-6 text-xs text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => loadProjects(page + 1, true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -348,11 +348,8 @@ export function CanvasWorkspace({
|
||||
};
|
||||
}, [isFileDrag]);
|
||||
|
||||
// 性能优化:只渲染视口内的节点(虚拟化)
|
||||
// 性能优化:始终只渲染视口附近节点,避免打开大画布时一次性拉取所有云端图片。
|
||||
const visibleNodes = useMemo(() => {
|
||||
// 如果节点数量少于 50,直接渲染所有节点
|
||||
if (nodes.length < 50) return nodes;
|
||||
|
||||
return nodes.filter(node =>
|
||||
isNodeInViewport(node, viewport, containerSize) ||
|
||||
selectedNodeIds.includes(node.id) // 选中的节点总是渲染
|
||||
|
||||
@ -538,6 +538,7 @@ export function ImageNode({
|
||||
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
|
||||
const [notePanelOpen, setNotePanelOpen] = useState(false);
|
||||
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
|
||||
const [hasEnteredViewport, setHasEnteredViewport] = useState(false);
|
||||
const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>(() =>
|
||||
normalizeTextEditItems(data.textEditItems)
|
||||
);
|
||||
@ -798,6 +799,16 @@ export function ImageNode({
|
||||
}),
|
||||
[compareSource, displayImageWidth]
|
||||
);
|
||||
const shouldRequestDisplayImage =
|
||||
hasEnteredViewport ||
|
||||
isSelected ||
|
||||
isCompareMode ||
|
||||
boxCutoutMode ||
|
||||
isThisNodePicking ||
|
||||
fullscreenPreviewOpen ||
|
||||
annotatorOpen ||
|
||||
resizeAnnotatorOpen ||
|
||||
textEditPanelOpen;
|
||||
const [loadedResolution, setLoadedResolution] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
@ -1055,29 +1066,41 @@ export function ImageNode({
|
||||
|
||||
useEffect(() => {
|
||||
setLoadedResolution(null);
|
||||
if (!isSelected || !data.src || metadataResolution) return;
|
||||
}, [data.src]);
|
||||
|
||||
let canceled = false;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (canceled) return;
|
||||
const width = Math.round(img.naturalWidth || 0);
|
||||
const height = Math.round(img.naturalHeight || 0);
|
||||
if (width > 0 && height > 0) {
|
||||
setLoadedResolution({ width, height });
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (!canceled) {
|
||||
setLoadedResolution(null);
|
||||
}
|
||||
};
|
||||
img.src = data.src;
|
||||
useEffect(() => {
|
||||
setHasEnteredViewport(false);
|
||||
}, [data.src]);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [data.src, isSelected, metadataResolution]);
|
||||
useEffect(() => {
|
||||
if (!data.src || hasEnteredViewport || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = containerRef.current;
|
||||
if (!element || typeof IntersectionObserver === "undefined") {
|
||||
setHasEnteredViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setHasEnteredViewport(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
// 轻微提前加载,保证拖动画布时体验顺滑,但不请求远离视口的图片。
|
||||
rootMargin: "320px",
|
||||
threshold: 0.01,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [data.src, hasEnteredViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@ -4033,7 +4056,14 @@ export function ImageNode({
|
||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||
{data.src ? (
|
||||
<div className="relative h-full w-full">
|
||||
{isCompareMode && hasCompareSource ? (
|
||||
{!shouldRequestDisplayImage ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>图片即将进入视口时加载</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isCompareMode && hasCompareSource ? (
|
||||
<div
|
||||
className="relative h-full w-full cursor-col-resize select-none overflow-hidden bg-slate-50 dark:bg-zinc-900"
|
||||
data-canvas-no-drag="true"
|
||||
|
||||
@ -254,6 +254,8 @@ export interface CanvasProject {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type CanvasProjectListItem = Omit<CanvasProject, "canvasData">;
|
||||
|
||||
// 附件类型
|
||||
export interface Attachment {
|
||||
type: 'image' | 'file';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user