'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import {
  Brush,
  BookOpen,
  ChevronDown,
  Copy,
  Download,
  Eye,
  FileImage,
  FolderOpen,
  Image as ImageIcon,
  Layers,
  Link2,
  Loader2,
  Maximize2,
  MousePointer2,
  Plus,
  RotateCcw,
  Save,
  Sparkles,
  StickyNote,
  Trash2,
  Type,
  Undo2,
  Upload,
  Wand2,
  Redo2,
  RefreshCw,
  Send,
  ShoppingCart,
  UserRound,
  ZoomIn,
  ZoomOut,
  X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/lib/auth-store';
import { useCustomApiKeys } from '@/lib/custom-api-store';
import { useManagedSystemApis } from '@/lib/managed-model-store';
import {
  ASPECT_RATIOS,
  IMG2IMG_ASPECT_RATIOS,
  RESOLUTION_OPTIONS,
  buildCustomModelId,
  buildSystemModelId,
  getCustomKeyId,
  getSystemApiId,
  isCustomModel,
  isSystemModel,
  resolveCustomApiImageSize,
  resolveImageSize,
  resolveImageSizeFromDimensions,
} from '@/lib/model-config';
import { runGenerationJob } from '@/lib/generation-job-client';
import { cn } from '@/lib/utils';
import { createEmptyCanvasState, type CanvasAsset, type CanvasConnection, type CanvasLayer, type CanvasNode, type CanvasNodeType, type CanvasProject, type CanvasProjectState, type CanvasViewport } from '@/lib/canvas-types';
import type { CanvasFlowControls } from './react-flow-canvas';

const ReactFlowCanvas = dynamic(() => import('./react-flow-canvas').then(mod => mod.ReactFlowCanvas), {
  ssr: false,
});

type AddMenu = {
  x: number;
  y: number;
  left: number;
  top: number;
} | null;

type CanvasContextMenu = {
  x: number;
  y: number;
  left: number;
  top: number;
  targetNodeId?: string;
} | null;

type CanvasClipboard = {
  nodes: CanvasNode[];
  connections: CanvasConnection[];
} | null;

type LeftPanelTab = 'projects' | 'templates' | 'groups' | 'assets';

type FileInputWithDirectory = HTMLInputElement & {
  webkitdirectory: boolean;
  directory: boolean;
};

type ExternalAssetMap = Map<string, { url: string; name: string; type: 'image' | 'video' }>;

type WorkflowTemplateType =
  | 'text2img'
  | 'img2img'
  | 'image-chain'
  | 'layered'
  | 'multi-angle-storyboard'
  | 'product-ecommerce-full-set'
  | 'drama-character-design'
  | 'drama-scene-background'
  | 'picture-book-generator';

type WorkflowTemplateMeta = {
  id: WorkflowTemplateType;
  name: string;
  description: string;
  badge: string;
  icon: 'storyboard' | 'ecommerce' | 'character' | 'scene' | 'book';
  cover?: string;
};

const LOCAL_DRAFT_KEY = 'miaojing:canvas:last-project-id';
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
const LAYER_CANVAS_SIZE = 1080;
const LAYER_COLORS: Record<CanvasLayer['type'], string> = {
  background: '#f8fafc',
  element: '#38bdf8',
  icon: '#a78bfa',
  text: '#111827',
  effect: '#f59e0b',
};

const HUOBAO_WORKFLOW_TEMPLATES: WorkflowTemplateMeta[] = [
  {
    id: 'multi-angle-storyboard',
    name: '多角度分镜',
    description: '生成角色正视、侧视、后视、俯视四组分镜图。',
    badge: 'Storyboard',
    icon: 'storyboard',
    cover: '/canvas/workflows/workflow01.jpeg',
  },
  {
    id: 'product-ecommerce-full-set',
    name: '通用产品全套电商图',
    description: '根据产品信息和产品图生成模特图、展示图、拆解图。',
    badge: 'E-commerce',
    icon: 'ecommerce',
    cover: '/canvas/workflows/workflow02.jpeg',
  },
  {
    id: 'drama-character-design',
    name: '短剧角色设计',
    description: '从角色描述生成基准角色图，并衍生多角度角色素材。',
    badge: 'Drama',
    icon: 'character',
    cover: '/canvas/workflows/shot01.jpeg',
  },
  {
    id: 'drama-scene-background',
    name: '多时段场景背景',
    description: '为同一场景生成白天、黄昏、夜晚和雨天版本。',
    badge: 'Scene',
    icon: 'scene',
    cover: '/canvas/workflows/scene01.jpeg',
  },
  {
    id: 'picture-book-generator',
    name: '儿童绘本生成',
    description: '把故事设定拆成连续页面提示词和插画生成节点。',
    badge: 'Book',
    icon: 'book',
  },
];

const BASIC_WORKFLOW_TEMPLATES: Record<WorkflowTemplateType, Pick<WorkflowTemplateMeta, 'name' | 'badge'>> = {
  text2img: { name: '文生图流程', badge: 'T2I' },
  img2img: { name: '图生图流程', badge: 'I2I' },
  'image-chain': { name: '多轮精修流程', badge: 'Chain' },
  layered: { name: '图层 PSD 流程', badge: 'PSD' },
  'multi-angle-storyboard': { name: '多角度分镜', badge: 'Storyboard' },
  'product-ecommerce-full-set': { name: '通用产品全套电商图', badge: 'E-commerce' },
  'drama-character-design': { name: '短剧角色设计', badge: 'Drama' },
  'drama-scene-background': { name: '多时段场景背景', badge: 'Scene' },
  'picture-book-generator': { name: '儿童绘本生成', badge: 'Book' },
};

const QUICK_SUGGESTIONS = ['像个魔法森林', '三只不同的小猫', '生成多角度分镜', '夏日田野环绕漫步'];
const WORKFLOW_TEMPLATE_BOUNDS: Record<WorkflowTemplateType, { width: number; height: number }> = {
  text2img: { width: 1180, height: 420 },
  img2img: { width: 1220, height: 780 },
  'image-chain': { width: 2020, height: 420 },
  layered: { width: 1260, height: 500 },
  'multi-angle-storyboard': { width: 1260, height: 500 },
  'product-ecommerce-full-set': { width: 1260, height: 500 },
  'drama-character-design': { width: 1260, height: 500 },
  'drama-scene-background': { width: 1260, height: 500 },
  'picture-book-generator': { width: 1260, height: 500 },
};

function nowIso() {
  return new Date().toISOString();
}

function createId(prefix: string) {
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

function normalizeCanvasProjectState(value: unknown): CanvasProjectState {
  if (!value || typeof value !== 'object') return createEmptyCanvasState();
  const input = value as Partial<CanvasProjectState>;
  const fallback = createEmptyCanvasState();
  const viewport = input.viewport && typeof input.viewport === 'object' ? input.viewport : fallback.viewport;
  return {
    nodes: Array.isArray(input.nodes) ? input.nodes : [],
    connections: Array.isArray(input.connections) ? input.connections : [],
    groups: Array.isArray(input.groups) ? input.groups : [],
    assets: Array.isArray(input.assets) ? input.assets : [],
    viewport: {
      x: typeof viewport.x === 'number' ? viewport.x : fallback.viewport.x,
      y: typeof viewport.y === 'number' ? viewport.y : fallback.viewport.y,
      zoom: typeof viewport.zoom === 'number' ? Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewport.zoom)) : fallback.viewport.zoom,
    },
  };
}

function asRecord(value: unknown): Record<string, unknown> {
  return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

function firstString(...values: unknown[]) {
  return values.find((value): value is string => typeof value === 'string' && value.trim().length > 0)?.trim() || '';
}

function firstNumber(...values: unknown[]) {
  return values.find((value): value is number => typeof value === 'number' && Number.isFinite(value));
}

function firstArray(...values: unknown[]) {
  return values.find((value): value is unknown[] => Array.isArray(value)) || [];
}

const EXTERNAL_NODE_ARRAY_KEYS = ['nodes', 'elements', 'items', 'cells', 'blocks'] as const;
const EXTERNAL_CONNECTION_ARRAY_KEYS = ['connections', 'edges', 'links', 'lines', 'cells'] as const;
const EXTERNAL_CANVAS_CONTAINER_KEYS = ['canvas', 'graph', 'workflow', 'project', 'scene', 'document', 'data', 'state', 'project_json', 'projectData'] as const;

function isLikelyExternalConnectionType(value: unknown) {
  const normalized = typeof value === 'string' ? value.toLowerCase().replace(/[_\s-]+/g, '') : '';
  return normalized === 'edge' || normalized === 'line' || normalized === 'link' || normalized === 'connection';
}

function isLikelyExternalNode(value: unknown) {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
  const node = value as Record<string, unknown>;
  const data = asRecord(node.data);
  const settings = asRecord(node.settings || data.settings || data);
  const rawType = firstString(node.type, node.shape, data.type, settings.type);
  if (isLikelyExternalConnectionType(rawType)) return false;
  if (rawType) return true;
  if (firstString(node.nodeName, node.title, node.name, data.label, data.title, settings.title) && (node.position || firstNumber(node.x, data.x) !== undefined)) return true;
  return !!firstString(node.prompt, settings.prompt, node.text, settings.text, node.content, data.text, data.prompt);
}

function isLikelyExternalConnection(value: unknown) {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
  const connection = value as Record<string, unknown>;
  const source = asRecord(connection.source);
  const target = asRecord(connection.target);
  if (isLikelyExternalConnectionType(firstString(connection.type, connection.shape))) return true;
  return !!(
    firstString(connection.sourceNodeId, connection.sourceId, connection.fromNodeId, connection.fromId, connection.from, connection.source, source.nodeId, source.node, source.cell, source.id)
    && firstString(connection.targetNodeId, connection.targetId, connection.toNodeId, connection.toId, connection.to, connection.target, target.nodeId, target.node, target.cell, target.id)
  );
}

function findExternalArray(
  value: unknown,
  keys: readonly string[],
  predicate: (item: unknown) => boolean,
  depth = 0,
): unknown[] {
  if (!value || typeof value !== 'object' || Array.isArray(value) || depth > 5) return [];
  const input = value as Record<string, unknown>;
  for (const key of keys) {
    const direct = input[key];
    if (Array.isArray(direct) && direct.some(predicate)) return direct;
  }
  const preferredContainers = EXTERNAL_CANVAS_CONTAINER_KEYS
    .map(key => input[key])
    .filter(item => item && typeof item === 'object' && !Array.isArray(item));
  const otherContainers = Object.entries(input)
    .filter(([key, item]) => !EXTERNAL_CANVAS_CONTAINER_KEYS.includes(key as typeof EXTERNAL_CANVAS_CONTAINER_KEYS[number]) && item && typeof item === 'object' && !Array.isArray(item))
    .map(([, item]) => item);
  for (const child of [...preferredContainers, ...otherContainers]) {
    const nested = findExternalArray(child, keys, predicate, depth + 1);
    if (nested.length > 0) return nested;
  }
  return [];
}

function getExternalNodes(value: unknown): unknown[] {
  return findExternalArray(value, EXTERNAL_NODE_ARRAY_KEYS, isLikelyExternalNode);
}

function getExternalConnections(value: unknown): unknown[] {
  return findExternalArray(value, EXTERNAL_CONNECTION_ARRAY_KEYS, isLikelyExternalConnection);
}

function isExternalCanvasProject(value: unknown) {
  if (!value || typeof value !== 'object') return false;
  const input = asRecord(value);
  const nodes = getExternalNodes(value);
  if (nodes.length === 0) return false;
  return nodes.some(isLikelyExternalNode) || !!input.view || typeof input.projectName === 'string' || typeof input.name === 'string';
}

function getExternalAssetKey(value?: unknown) {
  if (typeof value !== 'string' || !value) return '';
  if (value.startsWith('@asset:')) return value.slice('@asset:'.length);
  return '';
}

function looksLikeExternalAssetPath(value: string) {
  return value.includes('/assets/')
    || value.startsWith('assets/')
    || /\.(png|jpe?g|webp|gif|avif)$/i.test(value);
}

function isImageFilePath(value: string) {
  return /\.(png|jpe?g|webp|gif|avif|bmp|svg)$/i.test(value.split('?')[0] || value);
}

function isVideoFilePath(value: string) {
  return /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(value.split('?')[0] || value);
}

function getAssetLookupKeys(pathOrName: string) {
  const normalized = pathOrName.replace(/\\/g, '/').replace(/^\.?\//, '');
  const name = normalized.split('/').pop() || normalized;
  const withoutExt = normalized.replace(/\.[^.]+$/, '');
  const nameWithoutExt = name.replace(/\.[^.]+$/, '');
  const afterAssets = normalized.includes('/assets/') ? normalized.split('/assets/').pop() || normalized : normalized.startsWith('assets/') ? normalized.slice('assets/'.length) : '';
  const afterResourceDir = normalized.match(/(?:^|\/)(?:assets|images|image|resources|resource|uploads|files|media)\//i)?.[0]
    ? normalized.replace(/^.*?(?:assets|images|image|resources|resource|uploads|files|media)\//i, '')
    : '';
  return Array.from(new Set([
    normalized,
    withoutExt,
    name,
    nameWithoutExt,
    afterAssets,
    afterAssets ? afterAssets.replace(/\.[^.]+$/, '') : '',
    afterResourceDir,
    afterResourceDir ? afterResourceDir.replace(/\.[^.]+$/, '') : '',
  ].filter(Boolean)));
}

function collectExternalAssetReferenceKeys(value: unknown) {
  const keys = new Set<string>();
  if (typeof value === 'string' && value) {
    const assetKey = getExternalAssetKey(value);
    if (assetKey) keys.add(assetKey);
    if (looksLikeExternalAssetPath(value)) {
      getAssetLookupKeys(value).forEach(key => keys.add(key));
    }
  } else if (value && typeof value === 'object' && !Array.isArray(value)) {
    const input = value as Record<string, unknown>;
    [
      input.assetId,
      input.asset_id,
      input.fileId,
      input.file_id,
      input.imageAssetId,
      input.image_asset_id,
      input.path,
      input.filePath,
      input.file_path,
      input.name,
      input.fileName,
      input.filename,
      input.url,
      input.src,
      input.fullUrl,
      input.thumbUrl,
    ].forEach(item => {
      collectExternalAssetReferenceKeys(item).forEach(key => keys.add(key));
    });
  }
  return keys;
}

function resolveExternalAssetUrl(value: unknown, assetMap?: ExternalAssetMap) {
  if (typeof value === 'string') {
    if (!value) return '';
    const assetKey = getExternalAssetKey(value);
    if (assetKey) return assetMap?.get(assetKey)?.url || '';
    for (const key of getAssetLookupKeys(value)) {
      const resolved = assetMap?.get(key)?.url;
      if (resolved) return resolved;
    }
    return value;
  }
  if (value && typeof value === 'object' && !Array.isArray(value)) {
    const input = value as Record<string, unknown>;
    const directUrl = firstString(input.url, input.src, input.fullUrl, input.thumbUrl);
    if (directUrl) return resolveExternalAssetUrl(directUrl, assetMap);
    for (const key of collectExternalAssetReferenceKeys(input)) {
      const resolved = assetMap?.get(key)?.url;
      if (resolved) return resolved;
    }
  }
  return '';
}

function collectExternalAssetKeys(value: unknown, keys = new Set<string>()) {
  if (typeof value === 'string' || (value && typeof value === 'object' && !Array.isArray(value))) {
    collectExternalAssetReferenceKeys(value).forEach(key => keys.add(key));
    if (typeof value !== 'string') {
      Object.values(value as Record<string, unknown>).forEach(item => collectExternalAssetKeys(item, keys));
    }
    return keys;
  }
  if (Array.isArray(value)) {
    value.forEach(item => collectExternalAssetKeys(item, keys));
    return keys;
  }
  return keys;
}

function getImportCanvasCandidate(payload: unknown) {
  if (!payload || typeof payload !== 'object') return payload;
  const parsed = payload as {
    state?: unknown;
    project_json?: unknown;
    files?: {
      'project.data.json'?: unknown;
      projectData?: unknown;
    };
  };
  return parsed.files?.['project.data.json']
    || parsed.files?.projectData
    || parsed.project_json
    || parsed.state
    || parsed;
}

function normalizeExternalNodeType(type: string, node: Record<string, unknown>, settings: Record<string, unknown>): CanvasNodeType {
  const normalized = type.toLowerCase().replace(/[_\s-]+/g, '');
  const data = asRecord(node.data);
  const hasImageContent = !!(
    firstString(node.content, node.imageUrl, node.url, node.src, data.imageUrl, data.url, data.src, data.content, settings.imageUrl, settings.url, settings.src, settings.content)
    || collectExternalAssetReferenceKeys(node.content).size > 0
    || collectExternalAssetReferenceKeys(data.imageUrl).size > 0
    || collectExternalAssetReferenceKeys(data.content).size > 0
    || collectExternalAssetReferenceKeys(settings.imageUrl).size > 0
    || collectExternalAssetReferenceKeys(settings.content).size > 0
  );
  const hasPrompt = !!firstString(node.prompt, data.prompt, settings.prompt, settings.textPrompt, settings.positivePrompt);
  if (normalized.includes('img2img') || normalized.includes('imagetoimage')) return 'img2img';
  if (normalized.includes('text2img') || normalized.includes('txt2img') || normalized.includes('texttoimage')) return 'text2img';
  if (normalized.includes('gen') || normalized.includes('generate')) return hasImageContent ? 'img2img' : 'text2img';
  if (hasPrompt) return hasImageContent ? 'img2img' : 'text2img';
  if (normalized.includes('text')) return 'text';
  if (normalized.includes('image') || normalized.includes('asset')) return 'image';
  if (firstString(settings.text, data.text, node.text, node.content) && !hasImageContent) return 'text';
  return Array.isArray(settings.generatedImages) && hasImageContent ? 'image' : 'text';
}

function normalizeExternalRatio(value: unknown) {
  if (typeof value !== 'string' || !value) return undefined;
  const match = value.match(/^(\d+)\s*x\s*(\d+)$/i);
  if (!match) return value;
  const width = Number(match[1]);
  const height = Number(match[2]);
  if (!width || !height) return undefined;
  const ratio = width / height;
  if (Math.abs(ratio - 16 / 9) < 0.03) return '16:9';
  if (Math.abs(ratio - 9 / 16) < 0.03) return '9:16';
  if (Math.abs(ratio - 4 / 3) < 0.03) return '4:3';
  if (Math.abs(ratio - 3 / 4) < 0.03) return '3:4';
  if (Math.abs(ratio - 1) < 0.03) return '1:1';
  return undefined;
}

function normalizeExternalResolution(value: unknown) {
  if (typeof value !== 'string' || !value) return undefined;
  const match = value.match(/^(\d+)\s*x\s*(\d+)$/i);
  if (!match) return value === '1080P' || value === '2K' || value === '4K' ? value : undefined;
  const maxSide = Math.max(Number(match[1]), Number(match[2]));
  if (maxSide >= 3600) return '4K';
  if (maxSide >= 1800) return '2K';
  return '1080P';
}

function firstExternalImageArray(...values: unknown[]) {
  return values.find((value): value is unknown[] => Array.isArray(value) && value.length > 0) || [];
}

function convertExternalCanvasProject(value: unknown, assetMap?: ExternalAssetMap): CanvasProjectState {
  const input = asRecord(value);
  const view = asRecord(input.view || input.viewport);
  const createdAt = nowIso();
  const nodes = getExternalNodes(value).map((rawNode, index): CanvasNode | null => {
    if (!isLikelyExternalNode(rawNode)) return null;
    const node = rawNode as Record<string, unknown>;
    const position = asRecord(node.position);
    const size = asRecord(node.size);
    const data = asRecord(node.data);
    const settings = asRecord(node.settings || data.settings || data);
    const rawType = firstString(node.type, node.shape, data.type, data.shape, settings.type, settings.shape);
    const type = normalizeExternalNodeType(rawType, node, settings);
    const id = firstString(node.id, data.id) || createId('external-node');
    const title = firstString(node.nodeName, node.title, node.name, data.label, data.title, settings.title) || '外部节点';
    const x = firstNumber(node.x, position.x, data.x) ?? 120 + index * 420;
    const y = firstNumber(node.y, position.y, data.y) ?? 120;
    const width = firstNumber(node.width, size.width, data.width) ?? 360;
    const height = firstNumber(node.height, size.height, data.height) ?? 420;
    if (type === 'text') {
      const text = firstString(settings.text, settings.prompt, node.text, node.prompt, node.content, data.text, data.prompt);
      return {
        id,
        type: 'text',
        x,
        y,
        width,
        height,
        zIndex: index + 1,
        title,
        text,
        createdAt,
        updatedAt: createdAt,
      };
    }
    const contentUrl = resolveExternalAssetUrl(node.content || node.imageUrl || node.url || node.src || data.imageUrl || data.url || data.src || data.content || settings.imageUrl || settings.url || settings.src || settings.content, assetMap);
    const thumbnailUrl = resolveExternalAssetUrl(node.thumbnailUrl || node.thumbnail || data.thumbnailUrl || settings.thumbnailUrl || settings.thumbnail, assetMap);
    const generatedImages = firstExternalImageArray(
      settings.generatedImages,
      settings.outputImages,
      settings.outputs,
      settings.results,
      settings.images,
      settings.generated,
      data.generatedImages,
      data.outputImages,
      data.outputs,
      data.results,
      data.images,
      data.generated,
      node.generatedImages,
      node.outputImages,
      node.outputs,
      node.results,
      node.images,
      node.generated,
    );
    const outputImages = generatedImages
      .map((item) => {
        if (typeof item === 'string') return resolveExternalAssetUrl(item, assetMap);
        if (!item || typeof item !== 'object') return '';
        const image = item as Record<string, unknown>;
        return resolveExternalAssetUrl(image.fullUrl || image.url || image.src || image.thumbUrl || image.assetId || image, assetMap);
      })
      .filter(Boolean);
    const imageUrl = outputImages[0] || contentUrl || thumbnailUrl;
    const prompt = firstString(node.prompt, data.prompt, settings.prompt, settings.textPrompt, settings.positivePrompt);
    const negativePrompt = firstString(node.negativePrompt, node.negative_prompt, settings.negativePrompt, settings.negative_prompt, data.negativePrompt, data.negative_prompt);
    const ratio = normalizeExternalRatio(settings.ratio || settings.aspectRatio || data.ratio || data.aspectRatio || node.ratio || node.aspectRatio);
    const resolution = normalizeExternalResolution(settings.resolution || settings.ratio || settings.previewSize || settings.previewActualSize || data.resolution || data.previewSize || node.resolution);
    return {
      id,
      type,
      x,
      y,
      width: Math.max(320, width),
      height: Math.max(260, height),
      zIndex: index + 1,
      title,
      prompt,
      negativePrompt: negativePrompt || undefined,
      imageUrl: type === 'image' ? imageUrl : undefined,
      referenceImage: type === 'img2img' ? imageUrl || undefined : undefined,
      outputImages: outputImages.length > 0 ? outputImages : imageUrl ? [imageUrl] : undefined,
      selectedOutput: imageUrl || undefined,
      status: settings.error ? 'failed' : imageUrl ? 'succeeded' : 'idle',
      error: typeof settings.error === 'string' ? settings.error : undefined,
      params: {
        model: typeof settings.model === 'string' ? settings.model : undefined,
        modelLabel: typeof settings.previewSourceModel === 'string' ? settings.previewSourceModel : undefined,
        aspectRatio: ratio,
        resolution,
        count: Math.max(1, outputImages.length || 1),
      },
      createdAt,
      updatedAt: createdAt,
    };
  }).filter((node): node is CanvasNode => !!node);

  const connections = getExternalConnections(value).map((rawConnection): CanvasConnection | null => {
    if (!rawConnection || typeof rawConnection !== 'object') return null;
    const connection = rawConnection as Record<string, unknown>;
    const source = asRecord(connection.source);
    const target = asRecord(connection.target);
    const sourceNodeId = firstString(connection.sourceNodeId, connection.sourceId, connection.fromNodeId, connection.fromId, connection.from, connection.source, source.nodeId, source.node, source.cell, source.id);
    const targetNodeId = firstString(connection.targetNodeId, connection.targetId, connection.toNodeId, connection.toId, connection.to, connection.target, target.nodeId, target.node, target.cell, target.id);
    if (!sourceNodeId || !targetNodeId) return null;
    return {
      id: firstString(connection.id) || createId('connection'),
      sourceNodeId,
      targetNodeId,
      label: firstString(connection.label) || undefined,
      createdAt,
    };
  }).filter((connection): connection is CanvasConnection => !!connection);

  const imageAssets: CanvasAsset[] = assetMap
    ? Array.from(new Map(
      Array.from(assetMap.entries())
        .filter(([, asset]) => asset.type === 'image')
        .map(([id, asset]) => [asset.url, { id, url: asset.url, name: asset.name, type: 'image' as const, createdAt }]),
    ).values())
    : [];

  return normalizeCanvasProjectState({
    nodes,
    connections,
    groups: [],
    assets: imageAssets,
    viewport: {
      x: typeof view.x === 'number' ? view.x : 0,
      y: typeof view.y === 'number' ? view.y : 0,
      zoom: typeof view.zoom === 'number' ? view.zoom : 1,
    },
  });
}

function getImportCanvasState(payload: unknown, assetMap?: ExternalAssetMap): CanvasProjectState {
  if (!payload || typeof payload !== 'object') return createEmptyCanvasState();
  const candidate = getImportCanvasCandidate(payload);
  return isExternalCanvasProject(candidate)
    ? convertExternalCanvasProject(candidate, assetMap)
    : normalizeCanvasProjectState(candidate);
}

function sameStringArray(a: string[], b: string[]) {
  return a.length === b.length && a.every((value, index) => value === b[index]);
}

function fileToDataUrl(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result || ''));
    reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
    reader.readAsDataURL(file);
  });
}

function getImageDimensions(src: string): Promise<{ width?: number; height?: number }> {
  return new Promise((resolve) => {
    const image = new Image();
    image.onload = () => resolve({ width: image.naturalWidth || image.width, height: image.naturalHeight || image.height });
    image.onerror = () => resolve({});
    image.src = src;
  });
}

function createNode(type: CanvasNodeType, x: number, y: number, zIndex: number, patch: Partial<CanvasNode> = {}): CanvasNode {
  const createdAt = nowIso();
  const base: CanvasNode = {
    id: createId(type),
    type,
    x,
    y,
    width: type === 'text' ? 300 : type === 'frame' ? 640 : 360,
    height: type === 'text' ? 190 : type === 'frame' ? 420 : 420,
    zIndex,
    title: type === 'text'
      ? '文本'
      : type === 'image'
        ? '图片'
        : type === 'text2img'
          ? '文生图'
          : type === 'img2img'
            ? '图生图'
            : type === 'frame'
              ? '分组框'
              : '图层图片',
    status: 'idle',
    params: {
      aspectRatio: type === 'img2img' ? 'original' : '1:1',
      resolution: '2K',
      count: 1,
      strength: 0.5,
    },
    createdAt,
    updatedAt: createdAt,
  };
  if (type === 'text') base.text = '双击右侧属性面板编辑文本内容';
  if (type === 'frame') {
    base.color = '#7c3aed';
    base.text = '把相关模块拖到这个区域内，用来整理一组创作流程。';
  }
  if (type === 'layeredImage') {
    base.layers = [
      { id: createId('layer'), name: '背景层', type: 'background', visible: true, locked: false, color: '#f8fafc', opacity: 1, x: 0, y: 0, width: 1080, height: 1080 },
      { id: createId('layer'), name: '主体元素层', type: 'element', visible: true, locked: false, color: '#38bdf8', opacity: 0.9, x: 120, y: 180, width: 840, height: 650 },
      { id: createId('layer'), name: '文字层', type: 'text', visible: true, locked: false, color: '#111827', opacity: 1, text: '可编辑文字', x: 120, y: 80, width: 840, height: 120 },
    ];
  }
  return { ...base, ...patch };
}

function getAuthHeaders(accessToken?: string | null) {
  return {
    'Content-Type': 'application/json',
    ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
  };
}

function getNodeImageUrl(node?: CanvasNode | null) {
  if (!node) return '';
  if (node.type === 'image') return node.imageUrl || '';
  return node.selectedOutput || node.outputImages?.[0] || '';
}

function getNodeImageDimensions(node?: CanvasNode | null) {
  if (!node) return {};
  const imageWidth = typeof node.imageWidth === 'number' && node.imageWidth > 0 ? node.imageWidth : undefined;
  const imageHeight = typeof node.imageHeight === 'number' && node.imageHeight > 0 ? node.imageHeight : undefined;
  if (imageWidth && imageHeight) return { width: imageWidth, height: imageHeight };
  const previewHeight = Math.max(1, node.height - 80);
  return {
    width: Math.max(1, Math.round(node.width)),
    height: Math.max(1, Math.round(previewHeight)),
  };
}

function getNodeTextValue(node?: CanvasNode | null) {
  if (!node || node.type !== 'text') return '';
  return node.text?.trim() || '';
}

function isRunnableGenerationNode(node?: CanvasNode | null) {
  return !!node && (node.type === 'text2img' || node.type === 'img2img' || node.type === 'image');
}

function isEditableTarget(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false;
  return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
}

function downloadBlob(blob: Blob, filename: string) {
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  link.remove();
  window.setTimeout(() => URL.revokeObjectURL(url), 500);
}

function downloadJson(value: unknown, filename: string) {
  downloadBlob(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }), filename);
}

function drawLayerPreview(ctx: CanvasRenderingContext2D, layer: CanvasLayer, scale = 1) {
  if (!layer.visible) return;
  const x = layer.x * scale;
  const y = layer.y * scale;
  const width = layer.width * scale;
  const height = layer.height * scale;
  ctx.save();
  ctx.globalAlpha = layer.opacity ?? 1;
  if (layer.type === 'text') {
    ctx.fillStyle = layer.color || LAYER_COLORS.text;
    ctx.font = `${Math.max(18, Math.min(72, height * 0.45))}px sans-serif`;
    ctx.textBaseline = 'middle';
    ctx.fillText(layer.text || layer.name, x + 12 * scale, y + height / 2, Math.max(24, width - 24 * scale));
  } else if (layer.assetUrl) {
    ctx.fillStyle = layer.color || LAYER_COLORS[layer.type];
    ctx.fillRect(x, y, width, height);
  } else if (layer.type === 'background') {
    ctx.fillStyle = layer.color || LAYER_COLORS.background;
    ctx.fillRect(x, y, width, height);
  } else {
    const radius = Math.min(24 * scale, width / 5, height / 5);
    ctx.fillStyle = layer.color || LAYER_COLORS[layer.type];
    ctx.beginPath();
    ctx.roundRect(x, y, width, height, radius);
    ctx.fill();
  }
  ctx.restore();
}

function renderLayeredNodeToCanvas(node: CanvasNode, size = LAYER_CANVAS_SIZE) {
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('当前浏览器不支持画布导出');
  ctx.clearRect(0, 0, size, size);
  const scale = size / LAYER_CANVAS_SIZE;
  (node.layers || []).forEach(layer => drawLayerPreview(ctx, layer, scale));
  return canvas;
}

function getCanvasBounds(nodes: CanvasNode[]) {
  if (nodes.length === 0) return null;
  const left = Math.min(...nodes.map(node => node.x));
  const top = Math.min(...nodes.map(node => node.y));
  const right = Math.max(...nodes.map(node => node.x + node.width));
  const bottom = Math.max(...nodes.map(node => node.y + node.height));
  return { left, top, right, bottom, width: right - left, height: bottom - top };
}

function syncGroupsWithFrames(state: CanvasProjectState): CanvasProjectState {
  if (state.groups.length === 0) return state;
  const nodes = state.nodes;
  const nextGroups = state.groups
    .map(group => {
      const frame = group.frameNodeId ? nodes.find(node => node.id === group.frameNodeId && node.type === 'frame') : null;
      if (!frame) return group;
      const nodeIds = nodes
        .filter(node => node.type !== 'frame')
        .filter(node => (
          node.x >= frame.x
          && node.y >= frame.y
          && node.x + node.width <= frame.x + frame.width
          && node.y + node.height <= frame.y + frame.height
        ))
        .map(node => node.id);
      if (sameStringArray(group.nodeIds, nodeIds)) return group;
      return { ...group, nodeIds, updatedAt: nowIso() };
    })
    .filter(group => group.nodeIds.length > 0 || !!group.frameNodeId);
  return { ...state, groups: nextGroups };
}

function WorkflowTemplateIcon({ icon, className }: { icon: WorkflowTemplateMeta['icon']; className?: string }) {
  const Icon = icon === 'ecommerce'
    ? ShoppingCart
    : icon === 'character'
      ? UserRound
      : icon === 'scene'
        ? ImageIcon
        : icon === 'book'
          ? BookOpen
          : Layers;
  return <Icon className={className} />;
}

export function InfiniteCanvasWorkspace() {
  const { user, accessToken } = useAuth();
  const { imageKeys, textKeys } = useCustomApiKeys();
  const managedSystemApis = useManagedSystemApis();
  const fileInputRef = useRef<HTMLInputElement>(null);
  const importInputRef = useRef<HTMLInputElement>(null);
  const importDirectoryInputRef = useRef<FileInputWithDirectory>(null);
  const canvasRef = useRef<HTMLDivElement>(null);
  const flowControlsRef = useRef<CanvasFlowControls | null>(null);
  const historyRef = useRef<{ past: CanvasProjectState[]; future: CanvasProjectState[]; skip: boolean }>({ past: [], future: [], skip: false });
  const [projects, setProjects] = useState<CanvasProject[]>([]);
  const [project, setProject] = useState<CanvasProject | null>(null);
  const [state, setState] = useState<CanvasProjectState>(() => createEmptyCanvasState());
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
  const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
  const [addMenu, setAddMenu] = useState<AddMenu>(null);
  const [contextMenu, setContextMenu] = useState<CanvasContextMenu>(null);
  const [clipboard, setClipboard] = useState<CanvasClipboard>(null);
  const [loading, setLoading] = useState(false);
  const [saving, setSaving] = useState(false);
  const [importingProject, setImportingProject] = useState(false);
  const [dirty, setDirty] = useState(false);
  const [creatingProject, setCreatingProject] = useState(false);
  const [connectingFromId, setConnectingFromId] = useState<string | null>(null);
  const [isDraggingFile, setIsDraggingFile] = useState(false);
  const [mounted, setMounted] = useState(false);
  const [showNodeMenu, setShowNodeMenu] = useState(false);
  const [leftPanelTab, setLeftPanelTab] = useState<LeftPanelTab>('projects');
  const [leftDrawerOpen, setLeftDrawerOpen] = useState(false);
  const [showProjectMenu, setShowProjectMenu] = useState(false);
  const [chatInput, setChatInput] = useState('');
  const [autoExecute, setAutoExecute] = useState(false);
  const [previewImage, setPreviewImage] = useState<{ src: string; title: string } | null>(null);

  const selectedNode = useMemo(
    () => state.nodes.find(node => node.id === selectedNodeId) || null,
    [selectedNodeId, state.nodes],
  );

  const selectedNodes = useMemo(
    () => state.nodes.filter(node => selectedNodeIds.includes(node.id)),
    [selectedNodeIds, state.nodes],
  );

  const selectedNodeConnections = useMemo(
    () => selectedNode
      ? state.connections.filter(connection => connection.sourceNodeId === selectedNode.id || connection.targetNodeId === selectedNode.id)
      : [],
    [selectedNode, state.connections],
  );

  const contextMenuNode = useMemo(
    () => contextMenu?.targetNodeId ? state.nodes.find(node => node.id === contextMenu.targetNodeId) || null : null,
    [contextMenu?.targetNodeId, state.nodes],
  );

  const contextMenuNodeHasOutgoing = useMemo(
    () => !!contextMenuNode && state.connections.some(connection => connection.sourceNodeId === contextMenuNode.id),
    [contextMenuNode, state.connections],
  );

  const canvasBounds = useMemo(() => getCanvasBounds(state.nodes), [state.nodes]);
  const selectedBounds = useMemo(() => getCanvasBounds(selectedNodes), [selectedNodes]);
  const canvasStats = useMemo(() => [
    { label: '模块', value: state.nodes.length },
    { label: '连线', value: state.connections.length },
    { label: '分组', value: state.groups.length },
    { label: '素材', value: state.assets.length },
  ], [state.assets.length, state.connections.length, state.groups.length, state.nodes.length]);

  const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive);
  const systemTextApis = managedSystemApis.filter(api => api.type === 'text' && api.isActive);
  const imageModelOptions = useMemo(() => [
    ...systemImageApis.map(api => ({
      id: buildSystemModelId(api.id),
      label: `${api.name} (系统)`,
      modelName: api.modelName,
      source: 'system' as const,
    })),
    ...imageKeys.map(key => ({
      id: buildCustomModelId(key.id),
      label: `${key.modelName || key.provider} (自定义)`,
      modelName: key.modelName,
      source: 'custom' as const,
    })),
  ], [systemImageApis, imageKeys]);
  const textModelOptions = useMemo(() => [
    ...systemTextApis.map(api => ({
      id: buildSystemModelId(api.id),
      label: `${api.name} (系统)`,
      modelName: api.modelName,
      source: 'system' as const,
    })),
    ...textKeys.map(key => ({
      id: buildCustomModelId(key.id),
      label: `${key.modelName || key.provider} (自定义)`,
      modelName: key.modelName,
      source: 'custom' as const,
    })),
  ], [systemTextApis, textKeys]);

  useEffect(() => {
    setMounted(true);
  }, []);

  const mutateState = useCallback((updater: (current: CanvasProjectState) => CanvasProjectState, options?: { history?: boolean; dirty?: boolean }) => {
    setState(current => {
      const next = syncGroupsWithFrames(updater(current));
      if (!historyRef.current.skip && options?.history !== false && next !== current) {
        historyRef.current.past = [...historyRef.current.past.slice(-49), current];
        historyRef.current.future = [];
      }
      return next;
    });
    if (options?.dirty !== false) {
      setDirty(true);
    }
  }, []);

  const replaceCanvasState = useCallback((nextState: CanvasProjectState, options?: { dirty?: boolean }) => {
    historyRef.current.skip = true;
    setState(nextState);
    window.setTimeout(() => {
      historyRef.current.skip = false;
    }, 0);
    setDirty(options?.dirty ?? false);
  }, []);

  const resetHistory = useCallback(() => {
    historyRef.current = { past: [], future: [], skip: false };
  }, []);

  const screenToCanvasPoint = useCallback((clientX: number, clientY: number, viewport: CanvasProjectState['viewport'] = state.viewport) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    const left = rect?.left || 0;
    const top = rect?.top || 0;
    return {
      x: (clientX - left - viewport.x) / viewport.zoom,
      y: (clientY - top - viewport.y) / viewport.zoom,
    };
  }, [state.viewport]);

  const getCanvasMenuPosition = useCallback((clientX: number, clientY: number) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    const menuWidth = 224;
    const menuHeight = 336;
    const padding = 8;
    if (!rect) return { left: clientX, top: clientY };
    return {
      left: Math.min(Math.max(clientX - rect.left, padding), Math.max(padding, rect.width - menuWidth - padding)),
      top: Math.min(Math.max(clientY - rect.top, padding), Math.max(padding, rect.height - menuHeight - padding)),
    };
  }, []);

  const getNodeIdFromEvent = useCallback((target: EventTarget | null) => {
    return target instanceof HTMLElement ? target.closest<HTMLElement>('[data-canvas-node]')?.dataset.nodeId || null : null;
  }, []);

  const getViewportCenterPoint = useCallback(() => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return { x: 160, y: 160 };
    return screenToCanvasPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
  }, [screenToCanvasPoint]);

  const loadProjects = useCallback(async () => {
    if (!accessToken) return;
    setLoading(true);
    try {
      const res = await fetch('/api/canvas/projects', { headers: getAuthHeaders(accessToken) });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '读取画布项目失败');
      const nextProjects = Array.isArray(data.projects) ? data.projects as CanvasProject[] : [];
      setProjects(nextProjects);
      const lastId = window.localStorage.getItem(LOCAL_DRAFT_KEY);
      const nextProject = nextProjects.find(item => item.id === lastId) || nextProjects[0] || null;
      if (nextProject) {
        setProject(nextProject);
        replaceCanvasState(normalizeCanvasProjectState(nextProject.state));
        resetHistory();
        setSelectedNodeId(null);
        setSelectedNodeIds([]);
        setDirty(false);
      }
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '读取画布项目失败');
    } finally {
      setLoading(false);
    }
  }, [accessToken, replaceCanvasState, resetHistory]);

  useEffect(() => {
    void loadProjects();
  }, [loadProjects]);

  const openProject = useCallback(async (id: string) => {
    if (!accessToken) return;
    setLoading(true);
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(id)}`, { headers: getAuthHeaders(accessToken) });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '读取画布失败');
      setProject(data.project as CanvasProject);
      replaceCanvasState(normalizeCanvasProjectState((data.project as CanvasProject).state));
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      window.localStorage.setItem(LOCAL_DRAFT_KEY, id);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '读取画布失败');
    } finally {
      setLoading(false);
    }
  }, [accessToken, replaceCanvasState, resetHistory]);

  const createProject = useCallback(async () => {
    if (!accessToken) {
      toast.error('请先登录后再使用无限画布');
      return;
    }
    setCreatingProject(true);
    try {
      const res = await fetch('/api/canvas/projects', {
        method: 'POST',
        headers: getAuthHeaders(accessToken),
        body: JSON.stringify({ title: `创作画布 ${new Date().toLocaleDateString('zh-CN')}` }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '创建画布失败');
      const nextProject = data.project as CanvasProject;
      setProjects(prev => [nextProject, ...prev]);
      setProject(nextProject);
      replaceCanvasState(normalizeCanvasProjectState(nextProject.state));
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
      toast.success('已创建画布');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '创建画布失败');
    } finally {
      setCreatingProject(false);
    }
  }, [accessToken, replaceCanvasState, resetHistory]);

  const saveProject = useCallback(async () => {
    if (!project || !accessToken) return;
    setSaving(true);
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, {
        method: 'PUT',
        headers: getAuthHeaders(accessToken),
        body: JSON.stringify({ title: project.title, state }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '保存失败');
      const nextProject = data.project as CanvasProject;
      setProject(nextProject);
      setProjects(prev => prev.map(item => item.id === nextProject.id ? nextProject : item));
      setDirty(false);
      toast.success('画布已保存');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '保存失败');
    } finally {
      setSaving(false);
    }
  }, [accessToken, project, state]);

  const deleteCurrentProject = useCallback(async () => {
    if (!project || !accessToken) return;
    const ok = window.confirm(`确定删除画布“${project.title}”吗？此操作不能撤销。`);
    if (!ok) return;
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, {
        method: 'DELETE',
        headers: getAuthHeaders(accessToken),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '删除失败');
      const remaining = projects.filter(item => item.id !== project.id);
      setProjects(remaining);
      const nextProject = remaining[0] || null;
      setProject(nextProject);
      replaceCanvasState(nextProject ? normalizeCanvasProjectState(nextProject.state) : createEmptyCanvasState());
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      if (nextProject) {
        window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
      } else {
        window.localStorage.removeItem(LOCAL_DRAFT_KEY);
      }
      toast.success('画布已删除');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '删除失败');
    }
  }, [accessToken, project, projects, replaceCanvasState, resetHistory]);

  const deleteProject = useCallback(async (target: CanvasProject) => {
    if (!accessToken) return;
    const ok = window.confirm(`确定删除工作流“${target.title}”吗？此操作不能撤销。`);
    if (!ok) return;
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(target.id)}`, {
        method: 'DELETE',
        headers: getAuthHeaders(accessToken),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '删除失败');
      const remaining = projects.filter(item => item.id !== target.id);
      setProjects(remaining);

      if (project?.id !== target.id) {
        toast.success('工作流已删除');
        return;
      }

      const nextProject = remaining[0] || null;
      setProject(nextProject);
      replaceCanvasState(nextProject ? normalizeCanvasProjectState(nextProject.state) : createEmptyCanvasState());
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      if (nextProject) {
        window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
      } else {
        window.localStorage.removeItem(LOCAL_DRAFT_KEY);
      }
      toast.success('工作流已删除');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '删除失败');
    }
  }, [accessToken, project, projects, replaceCanvasState, resetHistory]);

  const exportProjectJson = useCallback(() => {
    if (!project) return;
    const safeTitle = project.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'canvas';
    const exportedAt = nowIso();
    const normalizedState = normalizeCanvasProjectState(state);
    downloadJson({
      version: 2,
      format: 'miaojing-canvas-project',
      title: project.title,
      files: {
        'project.meta.json': {
          title: project.title,
          node_count: normalizedState.nodes.length,
          connection_count: normalizedState.connections.length,
          group_count: normalizedState.groups.length,
          asset_count: normalizedState.assets.length,
          created_at: project.createdAt,
          updated_at: exportedAt,
        },
        'project.data.json': normalizedState,
      },
      state: normalizedState,
    }, `${safeTitle}.miaojing-canvas.json`);
  }, [project, state]);

  const focusImportedState = useCallback((nextState: CanvasProjectState) => {
    const importedNodeIds = nextState.nodes.map(node => node.id);
    setSelectedNodeIds(importedNodeIds);
    setSelectedNodeId(importedNodeIds[0] || null);
    const bounds = getCanvasBounds(nextState.nodes);
    const controls = flowControlsRef.current;
    if (bounds && controls) {
      window.setTimeout(() => {
        void controls.fitBounds({
          x: bounds.left - 120,
          y: bounds.top - 120,
          width: bounds.width + 240,
          height: bounds.height + 240,
        }, { padding: 0, duration: 220 });
      }, 80);
    }
  }, []);

  const applyImportedProject = useCallback((parsed: { title?: unknown; projectName?: unknown; state?: unknown; nodes?: unknown }, assetMap?: ExternalAssetMap) => {
    const nextState = getImportCanvasState(parsed, assetMap);
    replaceCanvasState(nextState, { dirty: true });
    resetHistory();
    const title = typeof parsed.title === 'string' && parsed.title.trim()
      ? parsed.title.trim()
      : typeof parsed.projectName === 'string' && parsed.projectName.trim()
        ? parsed.projectName.trim()
        : '';
    if (project && title) {
      setProject({ ...project, title });
    }
    focusImportedState(nextState);
  }, [focusImportedState, project, replaceCanvasState, resetHistory]);

  const importProjectJson = useCallback(async (file: File) => {
    setImportingProject(true);
    try {
      toast.info('正在导入工作流');
      const text = await file.text();
      const parsed = JSON.parse(text) as { title?: unknown; projectName?: unknown; state?: unknown; nodes?: unknown };
      const importedState = getImportCanvasState(parsed);
      if (importedState.nodes.length === 0) {
        throw new Error('没有识别到可导入的工作流节点');
      }
      applyImportedProject(parsed);
      toast.success(`画布已导入，包含 ${importedState.nodes.length} 个节点`);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '导入失败');
    } finally {
      setImportingProject(false);
    }
  }, [applyImportedProject]);

  const importProjectDirectory = useCallback(async (files: FileList | File[]) => {
    setImportingProject(true);
    try {
      const allFiles = Array.from(files);
      const dataFile = allFiles.find(file => file.name === 'project.data.json')
        || allFiles.find(file => file.webkitRelativePath?.endsWith('/project.data.json'));
      if (!dataFile) throw new Error('没有找到 project.data.json');
      toast.info('正在读取工作流目录');
      const parsed = JSON.parse(await dataFile.text()) as { title?: unknown; projectName?: unknown; state?: unknown; nodes?: unknown };
      const importCandidate = getImportCanvasCandidate(parsed);
      const requiredAssetKeys = collectExternalAssetKeys(importCandidate);
      const assetFiles = allFiles.filter((file) => {
        const relativePath = file.webkitRelativePath || file.name;
        if (file.type.startsWith('video/') || isVideoFilePath(relativePath)) return false;
        if (file.type && !file.type.startsWith('image/') && !isImageFilePath(relativePath)) return false;
        if (!file.type && !isImageFilePath(relativePath)) return false;
        return getAssetLookupKeys(relativePath).some(key => requiredAssetKeys.has(key));
      });
      const assetMap: ExternalAssetMap = new Map();
      await Promise.all(assetFiles.map(async (file) => {
        const relativePath = file.webkitRelativePath || file.name;
        const name = relativePath.split('/').pop() || file.name;
        const url = await fileToDataUrl(file);
        const type = file.type.startsWith('video/') ? 'video' : 'image';
        getAssetLookupKeys(relativePath).forEach(key => {
          assetMap.set(key, { url, name, type });
        });
      }));
      const importedState = getImportCanvasState(parsed, assetMap);
      if (importedState.nodes.length === 0) {
        throw new Error('没有识别到可导入的工作流节点');
      }
      applyImportedProject(parsed, assetMap);
      toast.success(`工作流目录已导入，包含 ${importedState.nodes.length} 个节点`);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '导入失败');
    } finally {
      setImportingProject(false);
    }
  }, [applyImportedProject]);

  const handleImportFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = '';
    if (!file) return;
    void importProjectJson(file);
  }, [importProjectJson]);

  const handleImportDirectoryChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    event.target.value = '';
    if (!files || files.length === 0) return;
    void importProjectDirectory(files);
  }, [importProjectDirectory]);

  const setImportDirectoryInputRef = useCallback((element: HTMLInputElement | null) => {
    const directoryInput = element as FileInputWithDirectory | null;
    importDirectoryInputRef.current = directoryInput;
    if (!directoryInput) return;
    directoryInput.webkitdirectory = true;
    directoryInput.directory = true;
  }, []);

  const openImportDirectoryPicker = useCallback(() => {
    if (!project || importingProject) return;
    const input = importDirectoryInputRef.current;
    if (!input) {
      toast.error('目录导入控件尚未准备好');
      return;
    }
    toast.info('正在打开目录选择器');
    input.click();
  }, [importingProject, project]);

  useEffect(() => {
    if (!dirty || !project || !accessToken) return;
    const timer = window.setTimeout(() => {
      void saveProject();
    }, 2500);
    return () => window.clearTimeout(timer);
  }, [accessToken, dirty, project, saveProject]);

  const updateNode = useCallback((id: string, patch: Partial<CanvasNode>) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === id ? { ...node, ...patch, updatedAt: nowIso() } : node),
      groups: current.groups.map(group => group.frameNodeId === id ? {
        ...group,
        title: typeof patch.title === 'string' ? patch.title : group.title,
        color: typeof patch.color === 'string' ? patch.color : group.color,
        updatedAt: nowIso(),
      } : group),
    }));
  }, [mutateState]);

  const removeAsset = useCallback((assetId: string) => {
    mutateState(current => ({
      ...current,
      assets: current.assets.filter(asset => asset.id !== assetId),
    }));
  }, [mutateState]);

  const focusGroup = useCallback((groupId: string) => {
    const group = state.groups.find(item => item.id === groupId);
    if (!group) return;
    const ids = [group.frameNodeId, ...group.nodeIds].filter((id): id is string => !!id);
    const nodes = state.nodes.filter(node => ids.includes(node.id));
    setSelectedNodeIds(nodes.map(node => node.id));
    setSelectedNodeId(group.frameNodeId || nodes[0]?.id || null);
    const bounds = getCanvasBounds(nodes);
    const controls = flowControlsRef.current;
    if (bounds && controls) {
      void controls.fitBounds({
        x: bounds.left - 80,
        y: bounds.top - 80,
        width: bounds.width + 160,
        height: bounds.height + 160,
      }, { padding: 0, duration: 160 });
    }
  }, [state.groups, state.nodes]);

  const updateLayer = useCallback((nodeId: string, layerId: string, patch: Partial<CanvasLayer>) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        if (node.id !== nodeId) return node;
        return {
          ...node,
          updatedAt: nowIso(),
          layers: (node.layers || []).map(layer => layer.id === layerId ? { ...layer, ...patch } : layer),
        };
      }),
    }));
  }, [mutateState]);

  const addLayer = useCallback((nodeId: string, type: CanvasLayer['type']) => {
    const layer: CanvasLayer = {
      id: createId('layer'),
      name: type === 'background' ? '背景层' : type === 'text' ? '文字层' : type === 'icon' ? '图标层' : type === 'effect' ? '效果层' : '元素层',
      type,
      visible: true,
      locked: false,
      color: LAYER_COLORS[type],
      opacity: type === 'background' ? 1 : 0.9,
      text: type === 'text' ? '双击编辑文字' : undefined,
      x: type === 'background' ? 0 : 180,
      y: type === 'background' ? 0 : 180,
      width: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 720 : 360,
      height: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 120 : 320,
    };
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: [...(node.layers || []), layer], updatedAt: nowIso() } : node),
    }));
  }, [mutateState]);

  const removeLayer = useCallback((nodeId: string, layerId: string) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: (node.layers || []).filter(layer => layer.id !== layerId), updatedAt: nowIso() } : node),
    }));
  }, [mutateState]);

  const moveLayer = useCallback((nodeId: string, layerId: string, direction: -1 | 1) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        if (node.id !== nodeId) return node;
        const layers = [...(node.layers || [])];
        const index = layers.findIndex(layer => layer.id === layerId);
        const nextIndex = index + direction;
        if (index < 0 || nextIndex < 0 || nextIndex >= layers.length) return node;
        const [layer] = layers.splice(index, 1);
        layers.splice(nextIndex, 0, layer);
        return { ...node, layers, updatedAt: nowIso() };
      }),
    }));
  }, [mutateState]);

  const addNode = useCallback((type: CanvasNodeType, x: number, y: number, patch: Partial<CanvasNode> = {}) => {
    const zIndex = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1;
    const node = createNode(type, x, y, zIndex, patch);
    mutateState(current => ({ ...normalizeCanvasProjectState(current), nodes: [...current.nodes, node] }));
    setSelectedNodeId(node.id);
    setSelectedNodeIds([node.id]);
    setAddMenu(null);
    return node;
  }, [mutateState, state.nodes]);

  const addWorkflowTemplate = useCallback((type: WorkflowTemplateType, origin?: { x: number; y: number }, options?: { originIsCenter?: boolean }) => {
    const bounds = WORKFLOW_TEMPLATE_BOUNDS[type];
    const startX = origin
      ? options?.originIsCenter ? origin.x - bounds.width / 2 : origin.x
      : 120;
    const startY = origin
      ? options?.originIsCenter ? origin.y - bounds.height / 2 : origin.y
      : 120;
    const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
    const createdAt = nowIso();
    const nodes: CanvasNode[] = [];
    const connections: CanvasConnection[] = [];
    const pushNode = (nodeType: CanvasNodeType, x: number, y: number, patch: Partial<CanvasNode> = {}) => {
      const node = createNode(nodeType, x, y, baseZ + nodes.length + 1, patch);
      nodes.push(node);
      return node;
    };
    const connect = (source: CanvasNode, target: CanvasNode, label?: string) => {
      connections.push({
        id: createId('connection'),
        sourceNodeId: source.id,
        targetNodeId: target.id,
        label,
        createdAt,
      });
    };

    if (type === 'text2img') {
      const prompt = pushNode('text', startX, startY, { title: '提示词草稿', text: '在这里写画面主体、风格、光线、构图和细节要求。' });
      const result = pushNode('image', startX + 420, startY, { title: '生成图片', prompt: '高质量商业视觉，细节丰富，构图清晰', width: 420, height: 460 });
      connect(prompt, result, '提示词');
    } else if (type === 'img2img') {
      const reference = pushNode('image', startX, startY, { title: '参考图', width: 360, height: 360 });
      const prompt = pushNode('text', startX, startY + 420, { title: '改图要求', text: '描述保留哪些内容，以及需要替换的风格、背景、元素和细节。' });
      const result = pushNode('image', startX + 460, startY + 120, { title: '改图结果', prompt: '保留主体结构，提升质感和细节', width: 420, height: 460 });
      connect(reference, result, '参考图');
      connect(prompt, result, '提示词');
    } else if (type === 'image-chain') {
      const prompt = pushNode('text', startX, startY, { title: '创意方向', text: '先生成主视觉，再把结果作为参考继续细化。' });
      const first = pushNode('image', startX + 420, startY, { title: '第一轮结果', prompt: '主视觉概念图，清晰主体，完整构图', width: 420, height: 460 });
      const final = pushNode('image', startX + 900, startY, { title: '最终结果', prompt: '在保留构图基础上增强质感、材质、光影和细节', width: 420, height: 460 });
      connect(prompt, first, '提示词');
      connect(prompt, final, '提示词');
      connect(first, final, '参考图');
    } else {
      const prompt = pushNode('text', startX, startY, { title: '图层设计说明', text: '描述背景、主体元素、图标、文字和效果层的编辑要求。' });
      const generator = pushNode('text2img', startX + 380, startY, { title: '视觉生成', prompt: '可拆分图层的海报设计，背景、主体、图标和文字层次清晰' });
      const layered = pushNode('layeredImage', startX + 800, startY, { title: '图层整理与 PSD 导出', width: 460, height: 500 });
      connect(prompt, generator, '提示词');
      connect(generator, layered, '图层整理');
    }

    const templateBounds = getCanvasBounds(nodes);
    const templateMeta = BASIC_WORKFLOW_TEMPLATES[type];
    const frame = templateBounds ? createNode('frame', templateBounds.left - 48, templateBounds.top - 48, 0, {
      title: `${templateMeta.name}分组`,
      width: templateBounds.width + 96,
      height: templateBounds.height + 96,
      color: '#7c3aed',
      text: templateMeta.name,
      zIndex: 0,
    }) : null;
    const group = frame ? {
      id: createId('group'),
      title: frame.title,
      nodeIds: nodes.map(node => node.id),
      frameNodeId: frame.id,
      color: frame.color,
      createdAt,
      updatedAt: createdAt,
    } : null;

    mutateState(current => ({
      ...current,
      nodes: [...current.nodes, ...(frame ? [frame] : []), ...nodes],
      connections: [...current.connections, ...connections],
      groups: group ? [...current.groups, group] : current.groups,
    }));
    setSelectedNodeId(nodes[0]?.id || null);
    setSelectedNodeIds(nodes.map(node => node.id));
    setAddMenu(null);
    toast.success('已添加工作流模板');
  }, [mutateState, state.nodes]);

  const removeSelectedNode = useCallback(() => {
    const ids = selectedNodeIds.length > 0 ? selectedNodeIds : selectedNodeId ? [selectedNodeId] : [];
    if (ids.length === 0) return;
    const idSet = new Set(ids);
    mutateState(current => ({
      ...current,
      nodes: current.nodes.filter(node => !idSet.has(node.id)),
      connections: current.connections.filter(
        connection => !idSet.has(connection.sourceNodeId) && !idSet.has(connection.targetNodeId),
      ),
      groups: current.groups
        .filter(group => !group.frameNodeId || !idSet.has(group.frameNodeId))
        .map(group => ({
          ...group,
          nodeIds: group.nodeIds.filter(id => !idSet.has(id)),
          updatedAt: nowIso(),
        }))
        .filter(group => group.nodeIds.length > 0),
    }));
    setConnectingFromId(current => current && idSet.has(current) ? null : current);
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
  }, [mutateState, selectedNodeId, selectedNodeIds]);

  const copySelectedNodes = useCallback((options?: { silent?: boolean }) => {
    const ids = selectedNodeIds.length > 0 ? selectedNodeIds : selectedNodeId ? [selectedNodeId] : [];
    const idSet = new Set(ids);
    const nodes = state.nodes.filter(node => idSet.has(node.id) && node.type !== 'frame');
    if (nodes.length === 0) {
      if (!options?.silent) toast.info('先选择需要复制的节点');
      return false;
    }
    const connections = state.connections.filter(connection => idSet.has(connection.sourceNodeId) && idSet.has(connection.targetNodeId));
    setClipboard({ nodes, connections });
    if (!options?.silent) toast.success(`已复制 ${nodes.length} 个节点`);
    return true;
  }, [selectedNodeId, selectedNodeIds, state.connections, state.nodes]);

  const pasteClipboardNodes = useCallback((point?: { x: number; y: number }) => {
    if (!clipboard || clipboard.nodes.length === 0) {
      toast.info('剪贴板里没有节点');
      return;
    }
    const sourceBounds = getCanvasBounds(clipboard.nodes);
    const origin = point || getViewportCenterPoint();
    const dx = sourceBounds ? origin.x - sourceBounds.left : 32;
    const dy = sourceBounds ? origin.y - sourceBounds.top : 32;
    const createdAt = nowIso();
    const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
    const idMap = new Map<string, string>();
    const nodes = clipboard.nodes.map((source, index): CanvasNode => {
      const id = createId(source.type);
      idMap.set(source.id, id);
      return {
        ...source,
        id,
        title: `${source.title} 副本`,
        x: source.x + dx,
        y: source.y + dy,
        zIndex: baseZ + index + 1,
        createdAt,
        updatedAt: createdAt,
        layers: source.layers?.map(layer => ({ ...layer, id: createId('layer') })),
      };
    });
    const connections = clipboard.connections
      .map((connection): CanvasConnection | null => {
        const sourceNodeId = idMap.get(connection.sourceNodeId);
        const targetNodeId = idMap.get(connection.targetNodeId);
        if (!sourceNodeId || !targetNodeId) return null;
        return {
          ...connection,
          id: createId('connection'),
          sourceNodeId,
          targetNodeId,
          createdAt,
        };
      })
      .filter((connection): connection is CanvasConnection => !!connection);
    mutateState(current => ({
      ...normalizeCanvasProjectState(current),
      nodes: [...current.nodes, ...nodes],
      connections: [...current.connections, ...connections],
    }));
    setSelectedNodeId(nodes[0]?.id || null);
    setSelectedNodeIds(nodes.map(node => node.id));
    setContextMenu(null);
    setAddMenu(null);
    toast.success(`已粘贴 ${nodes.length} 个节点`);
  }, [clipboard, getViewportCenterPoint, mutateState, state.nodes]);

  const cutSelectedNodes = useCallback(() => {
    if (!copySelectedNodes({ silent: true })) return;
    removeSelectedNode();
    setContextMenu(null);
    toast.success('已剪切所选节点');
  }, [copySelectedNodes, removeSelectedNode]);

  const duplicateSelectedNode = useCallback(() => {
    const sourceNodes = selectedNodes.length > 0 ? selectedNodes : selectedNode ? [selectedNode] : [];
    if (sourceNodes.length === 0) return;
    const createdAt = nowIso();
    const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
    const duplicates = sourceNodes.map((source, index): CanvasNode => ({
      ...source,
      id: createId(source.type),
      title: `${source.title} 副本`,
      x: source.x + 32,
      y: source.y + 32,
      zIndex: baseZ + index + 1,
      createdAt,
      updatedAt: createdAt,
      layers: source.layers?.map(layer => ({ ...layer, id: createId('layer') })),
    }));
    mutateState(current => ({ ...normalizeCanvasProjectState(current), nodes: [...current.nodes, ...duplicates] }));
    setSelectedNodeId(duplicates[0]?.id || null);
    setSelectedNodeIds(duplicates.map(node => node.id));
  }, [mutateState, selectedNode, selectedNodes, state.nodes]);

  const addConnection = useCallback((sourceNodeId: string, targetNodeId: string, options?: { silent?: boolean }) => {
    if (sourceNodeId === targetNodeId) {
      toast.info('不能连接到同一个模块');
      return;
    }
    let added = false;
    let referenceApplied = false;
    mutateState(current => {
      const source = current.nodes.find(node => node.id === sourceNodeId);
      const target = current.nodes.find(node => node.id === targetNodeId);
      if (!source || !target) return current;
      const exists = current.connections.some(
        connection => connection.sourceNodeId === sourceNodeId && connection.targetNodeId === targetNodeId,
      );
      if (exists) {
        referenceApplied = !!getNodeImageUrl(source) && target.type === 'img2img' && target.referenceImage !== getNodeImageUrl(source);
        return referenceApplied
          ? {
            ...normalizeCanvasProjectState(current),
            nodes: current.nodes.map(node => node.id === targetNodeId ? { ...node, referenceImage: getNodeImageUrl(source), updatedAt: nowIso() } : node),
          }
          : current;
      }
      const sourceImage = getNodeImageUrl(source);
      added = true;
      referenceApplied = !!sourceImage && target.type === 'img2img';
      const connection: CanvasConnection = {
        id: createId('connection'),
        sourceNodeId,
        targetNodeId,
        createdAt: nowIso(),
      };
      return {
        ...normalizeCanvasProjectState(current),
        nodes: referenceApplied
          ? current.nodes.map(node => node.id === targetNodeId ? { ...node, referenceImage: sourceImage, updatedAt: nowIso() } : node)
          : current.nodes,
        connections: [...current.connections, connection],
      };
    });
    if (!options?.silent) {
      if (added && referenceApplied) toast.success('已连接，并将图片设为图生图参考');
      else if (added) toast.success('已连接模块');
      else toast.info('这两个模块已经连接');
    }
  }, [mutateState]);

  const createVariationNodeFromCanvas = useCallback((sourceNodeId: string) => {
    const source = state.nodes.find(node => node.id === sourceNodeId);
    const sourceImage = getNodeImageUrl(source);
    if (!source || !sourceImage) {
      toast.info('当前节点没有可继续创作的图片');
      return;
    }
    const createdAt = nowIso();
    const zIndex = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1;
    const variation = createNode('img2img', source.x + source.width + 64, source.y, zIndex, {
      title: `${source.title}续创`,
      prompt: '基于参考图继续优化，保留主体结构，增强质感、光影和细节',
      referenceImage: sourceImage,
      width: 420,
      height: 460,
      params: {
        aspectRatio: 'original',
        resolution: '2K',
        count: 1,
        strength: 0.5,
      },
    });
    const connection: CanvasConnection = {
      id: createId('connection'),
      sourceNodeId: source.id,
      targetNodeId: variation.id,
      label: '参考图',
      createdAt,
    };
    mutateState(current => ({
      ...normalizeCanvasProjectState(current),
      nodes: [...current.nodes, variation],
      connections: current.connections.some(item => item.sourceNodeId === source.id && item.targetNodeId === variation.id)
        ? current.connections
        : [...current.connections, connection],
    }));
    setSelectedNodeId(variation.id);
    setSelectedNodeIds([variation.id]);
    setContextMenu(null);
    setAddMenu(null);
    toast.success('已创建下游图生图节点');
  }, [mutateState, state.nodes]);

  const removeConnection = useCallback((connectionId: string) => {
    mutateState(current => ({
      ...current,
      connections: current.connections.filter(connection => connection.id !== connectionId),
    }));
  }, [mutateState]);

  const alignSelectedNodes = useCallback((mode: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
    if (!selectedBounds || selectedNodes.length < 2) return;
    const idSet = new Set(selectedNodes.map(node => node.id));
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        if (!idSet.has(node.id)) return node;
        if (mode === 'left') return { ...node, x: selectedBounds.left, updatedAt: nowIso() };
        if (mode === 'center') return { ...node, x: selectedBounds.left + selectedBounds.width / 2 - node.width / 2, updatedAt: nowIso() };
        if (mode === 'right') return { ...node, x: selectedBounds.right - node.width, updatedAt: nowIso() };
        if (mode === 'top') return { ...node, y: selectedBounds.top, updatedAt: nowIso() };
        if (mode === 'middle') return { ...node, y: selectedBounds.top + selectedBounds.height / 2 - node.height / 2, updatedAt: nowIso() };
        return { ...node, y: selectedBounds.bottom - node.height, updatedAt: nowIso() };
      }),
    }));
  }, [mutateState, selectedBounds, selectedNodes]);

  const distributeSelectedNodes = useCallback((axis: 'x' | 'y') => {
    if (selectedNodes.length < 3) return;
    const sorted = [...selectedNodes].sort((a, b) => axis === 'x' ? a.x - b.x : a.y - b.y);
    const first = sorted[0];
    const last = sorted[sorted.length - 1];
    if (!first || !last) return;
    const start = axis === 'x' ? first.x : first.y;
    const end = axis === 'x' ? last.x : last.y;
    const step = (end - start) / (sorted.length - 1);
    const positions = new Map(sorted.map((node, index) => [node.id, start + step * index]));
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        const value = positions.get(node.id);
        if (typeof value !== 'number') return node;
        return axis === 'x' ? { ...node, x: value, updatedAt: nowIso() } : { ...node, y: value, updatedAt: nowIso() };
      }),
    }));
  }, [mutateState, selectedNodes]);

  const createFrameAroundSelection = useCallback(() => {
    const targetNodes = selectedNodes.filter(node => node.type !== 'frame');
    const bounds = getCanvasBounds(targetNodes);
    if (!bounds) {
      toast.info('先选择需要整理到分组框里的模块');
      return;
    }
    const padding = 48;
    const frame = addNode('frame', bounds.left - padding, bounds.top - padding, {
      title: '分组框',
      width: bounds.width + padding * 2,
      height: bounds.height + padding * 2,
      color: '#7c3aed',
      text: '流程分组',
      zIndex: 0,
    });
    const timestamp = nowIso();
    const targetNodeIds = targetNodes.map(node => node.id);
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === frame.id ? { ...node, zIndex: 0 } : node),
      groups: [
        ...current.groups,
        {
          id: createId('group'),
          title: frame.title,
          nodeIds: targetNodeIds,
          frameNodeId: frame.id,
          color: frame.color,
          createdAt: timestamp,
          updatedAt: timestamp,
        },
      ],
    }));
    setSelectedNodeIds([frame.id, ...targetNodeIds]);
    setSelectedNodeId(frame.id);
  }, [addNode, mutateState, selectedNodes]);

  const handleConnectorClick = useCallback((nodeId: string, role: 'input' | 'output') => {
    if (role === 'output') {
      setConnectingFromId(current => current === nodeId ? null : nodeId);
      setSelectedNodeId(nodeId);
      setSelectedNodeIds([nodeId]);
      return;
    }
    if (!connectingFromId) {
      toast.info('先点击一个模块右侧的输出端口');
      return;
    }
    addConnection(connectingFromId, nodeId);
    setConnectingFromId(null);
    setSelectedNodeId(nodeId);
    setSelectedNodeIds([nodeId]);
  }, [addConnection, connectingFromId]);

  const addImageAsset = useCallback(async (file: File, point?: { x: number; y: number }) => {
    const dataUrl = await fileToDataUrl(file);
    const dimensions = await getImageDimensions(dataUrl);
    const asset: CanvasAsset = {
      id: createId('asset'),
      url: dataUrl,
      name: file.name || '上传图片',
      type: 'image',
      createdAt: nowIso(),
    };
    mutateState(current => ({ ...normalizeCanvasProjectState(current), assets: [asset, ...current.assets] }));
    addNode('image', point?.x ?? 120, point?.y ?? 120, {
      title: file.name || '图片',
      imageUrl: dataUrl,
      imageWidth: dimensions.width,
      imageHeight: dimensions.height,
      width: Math.min(520, Math.max(260, (dimensions.width || 600) / 2)),
      height: Math.min(520, Math.max(240, (dimensions.height || 420) / 2 + 80)),
    });
  }, [addNode, mutateState]);

  const renderSingleLayerToCanvas = useCallback((layer: CanvasLayer) => {
    const canvas = document.createElement('canvas');
    canvas.width = LAYER_CANVAS_SIZE;
    canvas.height = LAYER_CANVAS_SIZE;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('当前浏览器不支持 PSD 图层导出');
    drawLayerPreview(ctx, { ...layer, visible: true }, 1);
    return canvas;
  }, []);

  const exportLayeredNode = useCallback(async (node: CanvasNode, format: 'png' | 'json' | 'psd') => {
    try {
      const safeTitle = node.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'layered-image';
      if (format === 'json') {
        downloadBlob(
          new Blob([JSON.stringify({ version: 1, title: node.title, layers: node.layers || [] }, null, 2)], { type: 'application/json' }),
          `${safeTitle}.layers.json`,
        );
        return;
      }
      const canvas = renderLayeredNodeToCanvas(node);
      if (format === 'png') {
        canvas.toBlob(blob => {
          if (blob) downloadBlob(blob, `${safeTitle}.png`);
        }, 'image/png');
        return;
      }
      const { writePsd } = await import('ag-psd');
      type AgPsdLayer = {
        name: string;
        top: number;
        left: number;
        bottom: number;
        right: number;
        hidden?: boolean;
        opacity?: number;
        canvas?: HTMLCanvasElement;
        text?: {
          text: string;
          transform: [number, number, number, number, number, number];
          style: {
            font: { name: string };
            fontSize: number;
            fillColor: { r: number; g: number; b: number };
          };
        };
      };
      const children: AgPsdLayer[] = (node.layers || []).map(layer => {
        const top = Math.max(0, Math.round(layer.y));
        const left = Math.max(0, Math.round(layer.x));
        const bottom = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.y + layer.height));
        const right = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.x + layer.width));
        const canvasLayer: AgPsdLayer = {
          name: layer.name,
          top,
          left,
          bottom,
          right,
          hidden: !layer.visible,
          opacity: Math.round((layer.opacity ?? 1) * 255),
          canvas: renderSingleLayerToCanvas(layer),
        };
        if (layer.type === 'text') {
          const color = layer.color || LAYER_COLORS.text;
          const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
          canvasLayer.text = {
            text: layer.text || layer.name,
            transform: [1, 0, 0, 1, layer.x + 12, layer.y + Math.max(18, Math.min(72, layer.height * 0.45))],
            style: {
              font: { name: 'ArialMT' },
              fontSize: Math.max(18, Math.min(72, layer.height * 0.45)),
              fillColor: rgb
                ? { r: parseInt(rgb[1], 16), g: parseInt(rgb[2], 16), b: parseInt(rgb[3], 16) }
                : { r: 17, g: 24, b: 39 },
            },
          };
        }
        return canvasLayer;
      });
      const buffer = writePsd({
        width: LAYER_CANVAS_SIZE,
        height: LAYER_CANVAS_SIZE,
        children,
        canvas,
      }, { noBackground: true, invalidateTextLayers: true, generateThumbnail: true });
      downloadBlob(new Blob([buffer], { type: 'image/vnd.adobe.photoshop' }), `${safeTitle}.psd`);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '导出失败');
    }
  }, [renderSingleLayerToCanvas]);

  const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = '';
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      toast.error('请上传图片文件');
      return;
    }
    void addImageAsset(file, { x: 160, y: 160 });
  }, [addImageAsset]);

  const handlePasteImage = useCallback((event: ClipboardEvent) => {
    if (isEditableTarget(event.target)) return;
    const file = Array.from(event.clipboardData?.files || []).find(item => item.type.startsWith('image/'));
    if (!file) return;
    event.preventDefault();
    void addImageAsset(file, screenToCanvasPoint(window.innerWidth / 2, window.innerHeight / 2));
  }, [addImageAsset, screenToCanvasPoint]);

  useEffect(() => {
    window.addEventListener('paste', handlePasteImage);
    return () => window.removeEventListener('paste', handlePasteImage);
  }, [handlePasteImage]);

  const buildGenerationPayload = useCallback((node: CanvasNode, canvasState: CanvasProjectState = state) => {
    const incomingNodes = canvasState.connections
      .filter(connection => connection.targetNodeId === node.id)
      .map(connection => canvasState.nodes.find(item => item.id === connection.sourceNodeId))
      .filter((item): item is CanvasNode => !!item);
    const incomingPromptText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n');
    const incomingImageNode = incomingNodes.find(item => !!getNodeImageUrl(item));
    const referenceImage = node.referenceImage || getNodeImageUrl(incomingImageNode);
    const referenceDimensions = node.referenceImage
      ? canvasState.nodes.find(item => getNodeImageUrl(item) === node.referenceImage)
      : incomingImageNode;
    const basePrompt = node.prompt?.trim() || '';
    const prompt = incomingPromptText
      ? [incomingPromptText, basePrompt].filter(Boolean).join('\n\n')
      : basePrompt;
    const selectedModel = node.params?.model || imageModelOptions[0]?.id || '';
    const shouldUseReferenceImage = node.type === 'img2img' || !!referenceImage;
    const rawAspectRatio = node.params?.aspectRatio;
    const aspectRatio = rawAspectRatio && (shouldUseReferenceImage || rawAspectRatio !== 'original')
      ? rawAspectRatio
      : shouldUseReferenceImage ? 'original' : '1:1';
    const resolution = node.params?.resolution || '2K';
    const count = node.params?.count || 1;
    const isExternal = isCustomModel(selectedModel) || isSystemModel(selectedModel);
    const payload: Record<string, unknown> = {
      prompt,
      negativePrompt: node.negativePrompt?.trim() || undefined,
      model: selectedModel,
      aspectRatio,
      resolution,
      quality: resolution,
      count,
    };

    if (shouldUseReferenceImage) {
      payload.image = referenceImage;
      payload.strength = node.params?.strength ?? 0.5;
      const { width: referenceWidth, height: referenceHeight } = getNodeImageDimensions(referenceDimensions || node);
      payload.size = aspectRatio === 'original'
        ? resolveImageSizeFromDimensions(referenceWidth, referenceHeight, resolution)
        : isExternal
          ? resolveCustomApiImageSize(aspectRatio, resolution)
          : resolveImageSize(aspectRatio, resolution);
    } else {
      payload.size = isExternal
        ? resolveCustomApiImageSize(aspectRatio, resolution)
        : resolveImageSize(aspectRatio, resolution);
    }

    if (isCustomModel(selectedModel)) {
      const key = imageKeys.find(item => item.id === getCustomKeyId(selectedModel));
      if (key) {
        payload.model = key.modelName;
        payload.customApiConfig = { customApiKeyId: key.id, modelName: key.modelName };
      }
    } else if (isSystemModel(selectedModel)) {
      const api = systemImageApis.find(item => item.id === getSystemApiId(selectedModel));
      if (api) {
        payload.model = api.modelName;
        payload.customApiConfig = { systemApiId: api.id, modelName: api.modelName };
      }
    }

    return payload;
  }, [imageKeys, imageModelOptions, state, systemImageApis]);

  const generateForNode = useCallback(async (node: CanvasNode, options?: { canvasState?: CanvasProjectState; selectOutput?: boolean }) => {
    const canvasState = options?.canvasState || state;
    if (!user) {
      toast.error('请先登录后再生成');
      return null;
    }
    if (!isRunnableGenerationNode(node)) {
      toast.error('当前模块不能直接生成图片');
      return null;
    }
    if (!node.prompt?.trim()) {
      const hasIncomingText = canvasState.connections
        .filter(connection => connection.targetNodeId === node.id)
        .some(connection => {
          const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId);
          return !!getNodeTextValue(source);
        });
      if (!hasIncomingText) {
        toast.error('请输入创作描述，或连接文本模块作为提示词');
        return null;
      }
    }
    const hasIncomingImage = canvasState.connections
      .filter(connection => connection.targetNodeId === node.id)
      .some(connection => {
        const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId);
        return !!getNodeImageUrl(source);
      });
    if (node.type === 'img2img' && !node.referenceImage && !hasIncomingImage) {
      toast.error('图生图模块需要先选择参考图');
      return null;
    }
    updateNode(node.id, { status: 'generating', error: undefined, outputImages: [] });
    try {
      const writesResultToSelf = node.type === 'image';
      const payload = buildGenerationPayload(node, canvasState);
      const normalizedModel = String(payload.model || '').toLowerCase().replace(/[^a-z0-9]/g, '');
      const timeoutMs = normalizedModel === 'image2' || normalizedModel === 'gptimage2' ? 1_200_000 : 300_000;
      const data = await runGenerationJob<{ images?: string[]; referenceImage?: string; apiType?: 'stream' | 'sync'; error?: string }>(
        'image',
        payload,
        { timeoutMs },
      );
      if (!data.images || data.images.length === 0) {
        throw new Error(data.error || '生成结果为空');
      }
      const images = data.images;
      const apiType = data.apiType === 'stream' ? 'stream' : 'sync';
      const updatedAt = nowIso();
      const sourcePatch: Partial<CanvasNode> = {
        status: 'succeeded',
        outputImages: images,
        selectedOutput: images[0],
        ...(writesResultToSelf ? { imageUrl: images[0] } : {}),
        referenceImage: typeof data.referenceImage === 'string' ? data.referenceImage : node.referenceImage,
        params: { ...node.params, apiType },
      };
      const existingOutputNode = writesResultToSelf ? null : canvasState.connections
        .filter(connection => connection.sourceNodeId === node.id)
        .map(connection => canvasState.nodes.find(item => item.id === connection.targetNodeId))
        .find((item): item is CanvasNode => item?.type === 'image');
      const generatedOutputNode = writesResultToSelf
        ? {
          ...node,
          ...sourcePatch,
          imageUrl: images[0],
          updatedAt,
        }
        : existingOutputNode
        ? {
          ...existingOutputNode,
          imageUrl: images[0],
          outputImages: images,
          selectedOutput: images[0],
          status: 'succeeded' as const,
          updatedAt,
        }
        : createNode('image', node.x + node.width + 40, node.y, canvasState.nodes.reduce((max, item) => Math.max(max, item.zIndex), 0) + 1, {
          title: `${node.title}结果`,
          imageUrl: images[0],
          outputImages: images,
          selectedOutput: images[0],
          width: 380,
          height: 420,
          status: 'succeeded',
        });
      const outputConnection = existingOutputNode || writesResultToSelf
        ? null
        : {
          id: createId('connection'),
          sourceNodeId: node.id,
          targetNodeId: generatedOutputNode.id,
          label: '生成结果',
          createdAt: updatedAt,
        };
      const nextCanvasState: CanvasProjectState = {
        ...canvasState,
        nodes: canvasState.nodes
          .map(item => {
            if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt };
            if (item.id === generatedOutputNode.id) return generatedOutputNode;
            return item;
          })
          .concat(existingOutputNode || writesResultToSelf ? [] : [generatedOutputNode]),
        connections: outputConnection ? [...canvasState.connections, outputConnection] : canvasState.connections,
      };
      mutateState(current => ({
        ...current,
        nodes: current.nodes
          .map(item => {
            if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt };
            if (item.id === generatedOutputNode.id) return generatedOutputNode;
            return item;
          })
          .concat(current.nodes.some(item => item.id === generatedOutputNode.id) ? [] : [generatedOutputNode]),
        connections: outputConnection && !current.connections.some(item => item.sourceNodeId === outputConnection.sourceNodeId && item.targetNodeId === outputConnection.targetNodeId)
          ? [...current.connections, outputConnection]
          : current.connections,
      }));
      if (options?.selectOutput !== false) {
        setSelectedNodeId(generatedOutputNode.id);
        setSelectedNodeIds([generatedOutputNode.id]);
      }
      toast.success(`已生成 ${images.length} 张图片`);
      return { images, outputNodeId: generatedOutputNode.id, canvasState: nextCanvasState };
    } catch (error) {
      updateNode(node.id, { status: 'failed', error: error instanceof Error ? error.message : '生成失败' });
      toast.error(error instanceof Error ? error.message : '生成失败');
      return null;
    }
  }, [buildGenerationPayload, mutateState, state, updateNode, user]);

  const runDownstream = useCallback(async (node: CanvasNode) => {
    let workingState = state;
    const visited = new Set<string>();
    const queue = [node.id];
    const runnable: CanvasNode[] = isRunnableGenerationNode(node) ? [node] : [];
    while (queue.length > 0) {
      const currentId = queue.shift();
      if (!currentId || visited.has(currentId)) continue;
      visited.add(currentId);
      const outgoing = workingState.connections.filter(connection => connection.sourceNodeId === currentId);
      for (const connection of outgoing) {
        const target = workingState.nodes.find(item => item.id === connection.targetNodeId);
        if (!target || visited.has(target.id)) continue;
        if (isRunnableGenerationNode(target)) {
          runnable.push(target);
        }
        queue.push(target.id);
      }
    }
    const uniqueRunnable = runnable.filter((item, index, array) => array.findIndex(candidate => candidate.id === item.id) === index);
    if (uniqueRunnable.length === 0) {
      toast.info('下游没有可执行的生图模块');
      return;
    }
    let finalNodeId = node.id;
    for (const target of uniqueRunnable) {
      const latest = workingState.nodes.find(item => item.id === target.id) || target;
      const result = await generateForNode(latest, { canvasState: workingState, selectOutput: false });
      if (!result) return;
      workingState = result.canvasState;
      finalNodeId = result.outputNodeId;
    }
    setSelectedNodeId(finalNodeId);
    setSelectedNodeIds([finalNodeId]);
    toast.success('下游流程已执行完成');
  }, [generateForNode, state]);

  const handleRunNodeFromCanvas = useCallback((nodeId: string) => {
    const target = state.nodes.find(node => node.id === nodeId);
    if (target) void generateForNode(target);
  }, [generateForNode, state.nodes]);

  const handleRunDownstreamFromCanvas = useCallback((nodeId: string) => {
    const target = state.nodes.find(node => node.id === nodeId);
    if (target) void runDownstream(target);
  }, [runDownstream, state.nodes]);

  const addNodeAtViewportCenter = useCallback((type: CanvasNodeType, patch: Partial<CanvasNode> = {}) => {
    const point = getViewportCenterPoint();
    addNode(type, point.x, point.y, patch);
  }, [addNode, getViewportCenterPoint]);

  const downloadImageFromNode = useCallback(async (node: CanvasNode) => {
    const src = getNodeImageUrl(node);
    if (!src) {
      toast.info('当前图片节点没有可下载的图片');
      return;
    }
    const safeTitle = node.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'canvas-image';
    try {
      if (src.startsWith('data:')) {
        const [header, data] = src.split(',');
        const mime = header?.match(/^data:([^;]+)/)?.[1] || 'image/png';
        const binary = window.atob(data || '');
        const bytes = new Uint8Array(binary.length);
        for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
        downloadBlob(new Blob([bytes], { type: mime }), `${safeTitle}.${mime.includes('jpeg') ? 'jpg' : 'png'}`);
        return;
      }
      const response = await fetch(src);
      const blob = await response.blob();
      downloadBlob(blob, `${safeTitle}.${blob.type.includes('jpeg') ? 'jpg' : 'png'}`);
    } catch {
      window.open(src, '_blank', 'noopener,noreferrer');
    }
  }, []);

  const handlePreviewNodeFromCanvas = useCallback((nodeId: string) => {
    const target = state.nodes.find(node => node.id === nodeId);
    const src = getNodeImageUrl(target);
    if (!target || !src) return;
    setPreviewImage({ src, title: target.title });
  }, [state.nodes]);

  const handleDownloadNodeFromCanvas = useCallback((nodeId: string) => {
    const target = state.nodes.find(node => node.id === nodeId);
    if (target) void downloadImageFromNode(target);
  }, [downloadImageFromNode, state.nodes]);

  const handleFlowNodeSelect = useCallback((nodeId: string, additive: boolean) => {
    if (connectingFromId && connectingFromId !== nodeId) {
      addConnection(connectingFromId, nodeId);
      setConnectingFromId(null);
      setSelectedNodeId(nodeId);
      setSelectedNodeIds([nodeId]);
      return;
    }
    setAddMenu(null);
    setConnectingFromId(null);
    setSelectedNodeId(nodeId);
    setSelectedNodeIds(current => {
      if (!additive) return current.includes(nodeId) ? current : [nodeId];
      return current.includes(nodeId) ? current.filter(id => id !== nodeId) : [...current, nodeId];
    });
  }, [addConnection, connectingFromId]);

  const handleFlowNodesCommit = useCallback((positions: { id: string; x: number; y: number }[], options?: { history?: boolean; dirty?: boolean }) => {
    if (positions.length === 0) return;
    const positionMap = new Map(positions.map(position => [position.id, position]));
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        const position = positionMap.get(node.id);
        if (!position || (node.x === position.x && node.y === position.y)) return node;
        return { ...node, x: position.x, y: position.y, updatedAt: nowIso() };
      }),
    }), { history: options?.history ?? true, dirty: options?.dirty ?? true });
  }, [mutateState]);

  const handleFlowConnect = useCallback((sourceId: string, targetId: string) => {
    addConnection(sourceId, targetId);
    setConnectingFromId(null);
    setSelectedNodeId(targetId);
    setSelectedNodeIds([targetId]);
  }, [addConnection]);

  const handleFlowSelectionChange = useCallback((ids: string[]) => {
    setSelectedNodeIds(current => sameStringArray(current, ids) ? current : ids);
    setSelectedNodeId(current => current === (ids[0] || null) ? current : ids[0] || null);
  }, []);

  const handleRemoveFlowConnections = useCallback((ids: string[]) => {
    if (ids.length === 0) return;
    mutateState(current => ({
      ...current,
      connections: current.connections.filter(connection => !ids.includes(connection.id)),
    }));
  }, [mutateState]);

  const handleFlowStartConnect = useCallback((id: string) => {
    handleConnectorClick(id, 'output');
  }, [handleConnectorClick]);

  const handleViewportCommit = useCallback((viewport: CanvasViewport) => {
    const nextViewport = {
      x: viewport.x,
      y: viewport.y,
      zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewport.zoom)),
    };
    mutateState(current => ({ ...current, viewport: nextViewport }), { history: false, dirty: false });
  }, [mutateState]);

  const handlePaneClick = useCallback((point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => {
    setConnectingFromId(null);
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
    setAddMenu(null);
    setContextMenu(null);
  }, []);

  const handleCanvasContextMenu = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
    if (!project) return;
    event.preventDefault();
    const nodeId = getNodeIdFromEvent(event.target);
    const point = screenToCanvasPoint(event.clientX, event.clientY);
    const menuPosition = getCanvasMenuPosition(event.clientX, event.clientY);
    setAddMenu(null);
    setContextMenu({ x: point.x, y: point.y, ...menuPosition, targetNodeId: nodeId || undefined });
    if (nodeId && !selectedNodeIds.includes(nodeId)) {
      setSelectedNodeId(nodeId);
      setSelectedNodeIds([nodeId]);
    }
  }, [getCanvasMenuPosition, getNodeIdFromEvent, project, screenToCanvasPoint, selectedNodeIds]);

  const handlePaneDoubleClick = useCallback((point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => {
    setConnectingFromId(null);
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
    setContextMenu(null);
    const menuPosition = getCanvasMenuPosition(event.clientX, event.clientY);
    setAddMenu({ x: point.x, y: point.y, ...menuPosition });
  }, [getCanvasMenuPosition]);

  const handleCanvasDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    if (!Array.from(event.dataTransfer?.items || []).some(item => item.kind === 'file' && item.type.startsWith('image/'))) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = 'copy';
    setIsDraggingFile(true);
  }, []);

  const handleCanvasDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
    setIsDraggingFile(false);
  }, []);

  const handleCanvasDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    const files = Array.from(event.dataTransfer?.files || []).filter(file => file.type.startsWith('image/'));
    if (files.length === 0) return;
    event.preventDefault();
    setIsDraggingFile(false);
    const point = screenToCanvasPoint(event.clientX, event.clientY);
    files.slice(0, 6).forEach((file, index) => {
      void addImageAsset(file, { x: point.x + index * 28, y: point.y + index * 28 });
    });
  }, [addImageAsset, screenToCanvasPoint]);

  const setZoom = useCallback((zoom: number) => {
    const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
    const controls = flowControlsRef.current;
    if (controls) {
      void controls.zoomTo(nextZoom).then(() => {
        handleViewportCommit(controls.getViewport());
      });
      return;
    }
    mutateState(current => ({
      ...current,
      viewport: { ...current.viewport, zoom: nextZoom },
    }));
  }, [handleViewportCommit, mutateState]);

  const undoCanvas = useCallback(() => {
    const previous = historyRef.current.past.pop();
    if (!previous) return;
    historyRef.current.future = [state, ...historyRef.current.future.slice(0, 49)];
    replaceCanvasState(previous, { dirty: true });
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
  }, [replaceCanvasState, state]);

  const redoCanvas = useCallback(() => {
    const next = historyRef.current.future.shift();
    if (!next) return;
    historyRef.current.past = [...historyRef.current.past.slice(-49), state];
    replaceCanvasState(next, { dirty: true });
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
  }, [replaceCanvasState, state]);

  const resetView = useCallback(() => {
    const nextViewport = { x: 0, y: 0, zoom: 1 };
    const controls = flowControlsRef.current;
    if (controls) {
      void controls.setViewport(nextViewport).then(() => {
        handleViewportCommit(controls.getViewport());
      });
      return;
    }
    mutateState(current => ({
      ...current,
      viewport: nextViewport,
    }));
  }, [handleViewportCommit, mutateState]);

  const fitToContent = useCallback(() => {
    const bounds = getCanvasBounds(state.nodes);
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!bounds || !rect) {
      resetView();
      return;
    }
    const padding = 96;
    const zoom = Math.min(
      MAX_ZOOM,
      Math.max(MIN_ZOOM, Math.min((rect.width - padding * 2) / Math.max(bounds.width, 1), (rect.height - padding * 2) / Math.max(bounds.height, 1))),
    );
    const fitBounds = {
      x: bounds.left - padding,
      y: bounds.top - padding,
      width: bounds.width + padding * 2,
      height: bounds.height + padding * 2,
    };
    const controls = flowControlsRef.current;
    if (controls) {
      void controls.fitBounds(fitBounds, { padding: 0, duration: 120 }).then(() => {
        handleViewportCommit(controls.getViewport());
      });
      return;
    }
    mutateState(current => ({
      ...current,
      viewport: {
        zoom,
        x: rect.width / 2 - (bounds.left + bounds.width / 2) * zoom,
        y: rect.height / 2 - (bounds.top + bounds.height / 2) * zoom,
      },
    }));
  }, [handleViewportCommit, mutateState, resetView, state.nodes]);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (isEditableTarget(event.target)) return;
      if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
        event.preventDefault();
        void saveProject();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && event.shiftKey) {
        event.preventDefault();
        redoCanvas();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
        event.preventDefault();
        undoCanvas();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
        event.preventDefault();
        redoCanvas();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
        event.preventDefault();
        duplicateSelectedNode();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') {
        event.preventDefault();
        copySelectedNodes();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'x') {
        event.preventDefault();
        cutSelectedNodes();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') {
        event.preventDefault();
        pasteClipboardNodes();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
        event.preventDefault();
        const ids = state.nodes.map(node => node.id);
        setSelectedNodeIds(ids);
        setSelectedNodeId(ids[0] || null);
      } else if (event.key === 'Delete' || event.key === 'Backspace') {
        event.preventDefault();
        removeSelectedNode();
      } else if (event.key === 'Escape') {
        setAddMenu(null);
        setContextMenu(null);
        setConnectingFromId(null);
        setSelectedNodeIds([]);
        setSelectedNodeId(null);
      }
    };
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [copySelectedNodes, cutSelectedNodes, duplicateSelectedNode, pasteClipboardNodes, redoCanvas, removeSelectedNode, saveProject, state.nodes, undoCanvas]);

  const selectedNodeModelOptions = selectedNode?.type === 'text' ? textModelOptions : imageModelOptions;
  const activeModelValue = selectedNode?.params?.model || selectedNodeModelOptions[0]?.id || '';
  const selectedNodeCanGenerate = isRunnableGenerationNode(selectedNode);
  const selectedNodeHasIncomingImage = !!selectedNode && state.connections
    .filter(connection => connection.targetNodeId === selectedNode.id)
    .some(connection => {
      const source = state.nodes.find(node => node.id === connection.sourceNodeId);
      return !!getNodeImageUrl(source);
    });
  const selectedNodeAspectOptions = selectedNode?.type === 'text2img' || (selectedNode?.type === 'image' && !selectedNode.referenceImage && !selectedNodeHasIncomingImage)
    ? ASPECT_RATIOS
    : IMG2IMG_ASPECT_RATIOS;

  if (!mounted) {
    return (
      <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center bg-background px-4">
        <div className="max-w-md rounded-xl border bg-card p-6 text-center shadow-sm">
          <Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
          <p className="mt-3 text-sm text-muted-foreground">正在打开无限画布</p>
        </div>
      </div>
    );
  }

  if (!user) {
    return (
      <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center bg-background px-4">
        <div className="max-w-md rounded-xl border bg-card p-6 text-center shadow-sm">
          <Sparkles className="mx-auto h-10 w-10 text-primary" />
          <h1 className="mt-4 text-xl font-semibold">登录后使用无限画布</h1>
          <p className="mt-2 text-sm text-muted-foreground">画布项目会保存到你的账号下，可用于多轮图像创作和后续编辑。</p>
        </div>
      </div>
    );
  }

  return (
    <div className="relative flex h-[calc(100vh-4rem)] min-h-[720px] overflow-hidden bg-background">
      <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
      <input ref={importInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleImportFileChange} />
      <input
        ref={setImportDirectoryInputRef}
        type="file"
        className="hidden"
        multiple
        onChange={handleImportDirectoryChange}
      />

      <aside className="hidden w-14 shrink-0 flex-col items-center gap-2 border-r bg-background/95 px-2 py-3 text-foreground shadow-sm lg:flex">
        {[
          { id: 'projects' as const, label: '项目', icon: FolderOpen },
          { id: 'templates' as const, label: '模板', icon: Wand2 },
          { id: 'groups' as const, label: '分组', icon: Maximize2 },
          { id: 'assets' as const, label: '素材', icon: ImageIcon },
        ].map(item => {
          const Icon = item.icon;
          const active = leftDrawerOpen && leftPanelTab === item.id;
          return (
            <button
              key={item.id}
              type="button"
              title={item.label}
              className={cn(
                'flex h-10 w-10 items-center justify-center rounded-md border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
                active ? 'border-primary bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground' : 'border-transparent',
              )}
              onClick={() => {
                setLeftPanelTab(item.id);
                setLeftDrawerOpen(current => item.id === leftPanelTab ? !current : true);
              }}
            >
              <Icon className="h-4 w-4" />
            </button>
          );
        })}
        <div className="my-1 h-px w-8 bg-border" />
        <button
          type="button"
          className="flex h-10 w-10 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
          title="新建画布"
          onClick={createProject}
          disabled={creatingProject}
        >
          {creatingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
        </button>
        <button
          type="button"
          className="flex h-10 w-10 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
          title="上传图片"
          onClick={() => fileInputRef.current?.click()}
        >
          <Upload className="h-4 w-4" />
        </button>
        <div className="my-1 h-px w-8 bg-border" />
        <button
          type="button"
          className="flex h-10 w-10 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
          title="添加文本节点"
          onClick={() => addNodeAtViewportCenter('text')}
          disabled={!project}
        >
          <Type className="h-4 w-4" />
        </button>
        <button
          type="button"
          className="flex h-10 w-10 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
          title="添加图片结果节点"
          onClick={() => addNodeAtViewportCenter('image', { title: '生成图片', width: 420, height: 460 })}
          disabled={!project}
        >
          <ImageIcon className="h-4 w-4" />
        </button>
        <button
          type="button"
          className="flex h-10 w-10 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
          title="添加图层节点"
          onClick={() => addNodeAtViewportCenter('layeredImage')}
          disabled={!project}
        >
          <Layers className="h-4 w-4" />
        </button>
        <button
          type="button"
          className="flex h-10 w-10 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
          title="添加分组框"
          onClick={() => addNodeAtViewportCenter('frame')}
          disabled={!project}
        >
          <Maximize2 className="h-4 w-4" />
        </button>
      </aside>

      {leftDrawerOpen ? (
      <aside className="absolute bottom-0 left-14 top-0 z-40 hidden w-[340px] shrink-0 flex-col overflow-hidden border-r bg-background/95 text-foreground shadow-xl backdrop-blur lg:flex">
        <div className="flex shrink-0 items-center justify-between gap-3 border-b p-4 pb-3">
          <div>
            <h1 className="text-lg font-semibold">画布项目包</h1>
            <p className="text-xs text-muted-foreground">project.meta.json / project.data.json</p>
          </div>
          <Button size="icon" variant="ghost" onClick={() => setLeftDrawerOpen(false)} title="关闭面板">
            <X className="h-4 w-4" />
          </Button>
        </div>

        <div className="space-y-3 border-b px-4 py-3">
          <div className="rounded-lg border bg-muted/30 p-3">
            <div className="flex items-center justify-between gap-3">
              <div className="min-w-0">
                <div className="truncate text-sm font-semibold">{project?.title || '未选择项目'}</div>
                <div className="mt-1 text-xs text-muted-foreground">{project ? '当前正在编辑的画布包' : '创建或打开一个画布项目'}</div>
              </div>
              <Badge variant="secondary">V2</Badge>
            </div>
            <div className="mt-3 grid grid-cols-2 gap-2 text-xs">
              <div className="rounded-md border bg-background px-2 py-1.5 text-muted-foreground">project.meta.json</div>
              <div className="rounded-md border bg-background px-2 py-1.5 text-muted-foreground">project.data.json</div>
            </div>
          </div>
        </div>

        <div className="grid grid-cols-4 gap-2 px-4 py-3">
          {canvasStats.map(item => (
            <div key={item.label} className="rounded-md border bg-muted/30 px-2 py-2">
              <div className="text-base font-semibold leading-none text-primary">{item.value}</div>
              <div className="mt-1 text-[11px] text-muted-foreground">{item.label}</div>
            </div>
          ))}
        </div>

        <div className="grid grid-cols-4 gap-1 px-4 pb-3">
          {[
            { id: 'projects' as const, label: '项目', icon: FolderOpen },
            { id: 'templates' as const, label: '模板', icon: Wand2 },
            { id: 'groups' as const, label: '分组', icon: Maximize2 },
            { id: 'assets' as const, label: '素材', icon: ImageIcon },
          ].map(tab => {
            const Icon = tab.icon;
            return (
              <button
                key={tab.id}
                type="button"
                className={cn(
                  'flex h-9 items-center justify-center gap-1.5 rounded-md border text-xs transition-colors',
                  leftPanelTab === tab.id ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-muted/30 text-muted-foreground hover:bg-muted',
                )}
                onClick={() => setLeftPanelTab(tab.id)}
              >
                <Icon className="h-3.5 w-3.5" />
                {tab.label}
              </button>
            );
          })}
        </div>

        <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
          {leftPanelTab === 'projects' ? (
            <div className="space-y-2">
              <div className="flex items-center justify-between">
                <div className="text-xs font-semibold uppercase text-muted-foreground">工作流项目包</div>
                <button type="button" className="text-muted-foreground hover:text-foreground" onClick={() => void loadProjects()} title="刷新项目">
                  <RefreshCw className="h-3.5 w-3.5" />
                </button>
              </div>
              {loading ? (
                <div className="flex items-center gap-2 text-sm text-muted-foreground">
                  <Loader2 className="h-4 w-4 animate-spin" /> 读取工作流
                </div>
              ) : projects.length === 0 ? (
                <Button className="w-full justify-start gap-2 bg-muted/30 text-foreground hover:bg-muted" variant="outline" onClick={createProject}>
                  <Plus className="h-4 w-4" /> 创建第一个工作流
                </Button>
              ) : (
                projects.map(item => {
                  const itemState = normalizeCanvasProjectState(item.state);
                  return (
                    <div
                      key={item.id}
                      className={cn(
                        'group flex w-full items-start gap-2 rounded-lg border p-3 text-sm transition-colors',
                        project?.id === item.id ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-muted/30 text-foreground hover:bg-muted',
                      )}
                    >
                      <button className="min-w-0 flex-1 text-left" onClick={() => void openProject(item.id)}>
                        <div className="flex items-center gap-2">
                          <FolderOpen className="h-4 w-4 shrink-0" />
                          <div className="truncate font-medium">{item.title}</div>
                        </div>
                        <div className="mt-2 grid grid-cols-3 gap-1 text-[11px] text-muted-foreground">
                          <span className="rounded bg-background px-1.5 py-1">{itemState.nodes.length} nodes</span>
                          <span className="rounded bg-background px-1.5 py-1">{itemState.connections.length} links</span>
                          <span className="rounded bg-background px-1.5 py-1">{itemState.groups.length} groups</span>
                        </div>
                        <div className="mt-2 text-xs text-muted-foreground">
                          {itemState.nodes.length} 个模块 · {itemState.connections.length} 条连线 · {itemState.groups.length} 组
                        </div>
                      </button>
                      <button
                        type="button"
                        className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
                        title="删除工作流"
                        onClick={(event) => {
                          event.stopPropagation();
                          void deleteProject(item);
                        }}
                      >
                        <Trash2 className="h-3.5 w-3.5" />
                      </button>
                    </div>
                  );
                })
              )}
            </div>
          ) : null}

          {leftPanelTab === 'templates' ? (
            <div className="space-y-3">
              <div className="text-xs font-semibold uppercase text-muted-foreground">工作流模板</div>
              <div className="grid grid-cols-2 gap-2">
                <Button size="sm" variant="outline" className="justify-start gap-2 bg-muted/30 text-foreground hover:bg-muted" onClick={() => addWorkflowTemplate('text2img', getViewportCenterPoint(), { originIsCenter: true })} disabled={!project}>
                  <Brush className="h-4 w-4" /> 文生图
                </Button>
                <Button size="sm" variant="outline" className="justify-start gap-2 bg-muted/30 text-foreground hover:bg-muted" onClick={() => addWorkflowTemplate('img2img', getViewportCenterPoint(), { originIsCenter: true })} disabled={!project}>
                  <FileImage className="h-4 w-4" /> 图生图
                </Button>
                <Button size="sm" variant="outline" className="justify-start gap-2 bg-muted/30 text-foreground hover:bg-muted" onClick={() => addWorkflowTemplate('image-chain', getViewportCenterPoint(), { originIsCenter: true })} disabled={!project}>
                  <Wand2 className="h-4 w-4" /> 多轮精修
                </Button>
                <Button size="sm" variant="outline" className="justify-start gap-2 bg-muted/30 text-foreground hover:bg-muted" onClick={() => addWorkflowTemplate('layered', getViewportCenterPoint(), { originIsCenter: true })} disabled={!project}>
                  <Layers className="h-4 w-4" /> 图层 PSD
                </Button>
              </div>
              <div className="space-y-2">
                {HUOBAO_WORKFLOW_TEMPLATES.slice(0, 5).map(template => (
                  <button
                    key={template.id}
                    type="button"
                    className="flex w-full items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-left text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
                    onClick={() => addWorkflowTemplate(template.id, getViewportCenterPoint(), { originIsCenter: true })}
                    disabled={!project}
                  >
                    <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
                      <WorkflowTemplateIcon icon={template.icon} className="h-4 w-4" />
                    </div>
                    <div className="min-w-0">
                      <div className="truncate text-sm font-medium">{template.name}</div>
                      <div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{template.description}</div>
                      <div className="mt-2 inline-flex rounded bg-background px-1.5 py-1 text-[10px] text-muted-foreground">{template.badge}</div>
                    </div>
                  </button>
                ))}
              </div>
            </div>
          ) : null}

          {leftPanelTab === 'groups' ? (
            <div className="space-y-3">
              <div className="flex items-center justify-between">
                <div className="text-xs font-semibold uppercase text-muted-foreground">groups</div>
                <Badge variant="secondary">{state.groups.length}</Badge>
              </div>
              {state.groups.length === 0 ? (
                <div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
                  选择多个节点后点击“框住所选”，或添加工作流模板自动生成分组。
                </div>
              ) : (
                <div className="space-y-2">
                  {state.groups.map(group => {
                    const frame = group.frameNodeId ? state.nodes.find(node => node.id === group.frameNodeId) : null;
                    return (
                      <button
                        key={group.id}
                        type="button"
                        className={cn(
                          'w-full rounded-lg border bg-muted/30 p-3 text-left transition-colors hover:bg-muted',
                          selectedNodeIds.includes(group.frameNodeId || '') ? 'border-primary bg-primary/10' : 'border-border',
                        )}
                        onClick={() => focusGroup(group.id)}
                      >
                        <div className="flex items-center gap-2">
                          <span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: group.color || frame?.color || '#7c3aed' }} />
                          <div className="min-w-0 flex-1 truncate text-sm font-medium">{group.title}</div>
                        </div>
                        <div className="mt-2 grid grid-cols-2 gap-1 text-[11px] text-muted-foreground">
                          <span className="rounded bg-background px-1.5 py-1">{group.nodeIds.length} nodes</span>
                          <span className="rounded bg-background px-1.5 py-1">{frame ? 'frame linked' : 'no frame'}</span>
                        </div>
                      </button>
                    );
                  })}
                </div>
              )}
            </div>
          ) : null}

          {leftPanelTab === 'assets' ? (
            <div className="space-y-3">
              <div className="flex items-center justify-between">
                <div className="text-xs font-semibold uppercase text-muted-foreground">asset_files</div>
                <Badge className="bg-muted text-muted-foreground hover:bg-muted">{state.assets.length}</Badge>
              </div>
              <Button className="w-full justify-start gap-2 bg-muted/30 text-foreground hover:bg-muted" variant="outline" onClick={() => fileInputRef.current?.click()}>
                <Upload className="h-4 w-4" /> 上传图片
              </Button>
              {state.assets.length === 0 ? (
                <div className="rounded-lg border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">暂无素材文件</div>
              ) : (
                <div className="grid grid-cols-2 gap-2">
                  {state.assets.slice(0, 12).map(asset => (
                    <div
                      key={asset.id}
                      className="group relative aspect-square overflow-hidden rounded-lg border border-border bg-muted/30"
                    >
                      <button
                        type="button"
                        className="h-full w-full"
                        onClick={() => {
                          const point = getViewportCenterPoint();
                          addNode('image', point.x, point.y, { title: asset.name, imageUrl: asset.url });
                        }}
                      >
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img src={asset.url} alt={asset.name} className="h-full w-full object-cover" />
                        <span className="absolute inset-x-1 bottom-1 truncate rounded bg-background/80 px-1.5 py-1 text-left text-[10px] text-foreground opacity-0 transition-opacity group-hover:opacity-100">{asset.name}</span>
                      </button>
                      <button
                        type="button"
                        className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-md bg-background/85 text-destructive opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
                        title="从素材库移除"
                        onClick={(event) => {
                          event.stopPropagation();
                          removeAsset(asset.id);
                        }}
                      >
                        <Trash2 className="h-3.5 w-3.5" />
                      </button>
                    </div>
                  ))}
                </div>
              )}
            </div>
          ) : null}
        </div>
      </aside>
      ) : null}

      <section className="flex min-w-0 flex-1 flex-col">
        <div className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-3">
          <div className="flex min-w-0 items-center gap-2">
            <Button variant="outline" size="sm" className="gap-2 lg:hidden" onClick={createProject}>
              <Plus className="h-4 w-4" /> 新建
            </Button>
            <Input
              className="h-9 w-56 border-transparent bg-muted/50 font-medium focus-visible:border-border md:w-80"
              value={project?.title || ''}
              placeholder="未命名画布"
              onChange={(event) => {
                if (!project) return;
                setProject({ ...project, title: event.target.value });
                setDirty(true);
              }}
              disabled={!project}
            />
            {dirty ? <Badge variant="outline">未保存</Badge> : <Badge variant="secondary">已保存</Badge>}
          </div>

          <div className="flex items-center gap-2">
            <Button variant="outline" size="icon" onClick={undoCanvas} title="撤销">
              <Undo2 className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={redoCanvas} title="重做">
              <Redo2 className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={fitToContent} title="适应内容">
              <Maximize2 className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={resetView} title="重置视图">
              <RotateCcw className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={() => setZoom(state.viewport.zoom - 0.1)} title="缩小">
              <ZoomOut className="h-4 w-4" />
            </Button>
            <span className="w-12 text-center text-xs text-muted-foreground">{Math.round(state.viewport.zoom * 100)}%</span>
            <Button variant="outline" size="icon" onClick={() => setZoom(state.viewport.zoom + 0.1)} title="放大">
              <ZoomIn className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={() => void saveProject()} disabled={!project || saving}>
              {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} 保存
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={exportProjectJson} disabled={!project}>
              <Download className="h-4 w-4" /> 导出
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={() => importInputRef.current?.click()} disabled={!project || importingProject} title="导入 JSON">
              {importingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />} JSON
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={openImportDirectoryPicker} disabled={!project || importingProject} title="导入工作流目录">
              {importingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />} 目录
            </Button>
            <Button variant="outline" size="icon" className="text-destructive" onClick={() => void deleteCurrentProject()} disabled={!project} title="删除画布">
              <Trash2 className="h-4 w-4" />
            </Button>
          </div>
        </div>

        <div
          ref={canvasRef}
          className="relative flex-1 overflow-hidden bg-background"
          onDragOver={handleCanvasDragOver}
          onDragLeave={handleCanvasDragLeave}
          onDrop={handleCanvasDrop}
          onContextMenu={handleCanvasContextMenu}
        >
          <ReactFlowCanvas
            nodes={state.nodes}
            connections={state.connections}
            imageModelOptions={imageModelOptions.map(option => ({ id: option.id, label: option.label }))}
            textModelOptions={textModelOptions.map(option => ({ id: option.id, label: option.label }))}
            viewport={state.viewport}
            selectedNodeIds={selectedNodeIds}
            connectingFromId={connectingFromId}
            editable={!!project}
            minZoom={MIN_ZOOM}
            maxZoom={MAX_ZOOM}
            layerCanvasSize={LAYER_CANVAS_SIZE}
            layerColors={LAYER_COLORS}
            onSelectNode={handleFlowNodeSelect}
            onSelectionChange={handleFlowSelectionChange}
            onStartConnect={handleFlowStartConnect}
            onUpdateNode={updateNode}
            onRunNode={handleRunNodeFromCanvas}
            onRunDownstreamNode={handleRunDownstreamFromCanvas}
            onCreateVariationNode={createVariationNodeFromCanvas}
            onPreviewNode={handlePreviewNodeFromCanvas}
            onDownloadNode={handleDownloadNodeFromCanvas}
            onConnect={handleFlowConnect}
            onRemoveConnection={removeConnection}
            onRemoveConnections={handleRemoveFlowConnections}
            onNodesCommit={handleFlowNodesCommit}
            onViewportCommit={handleViewportCommit}
            onReady={(controls) => {
              flowControlsRef.current = controls;
            }}
            onPaneClick={handlePaneClick}
            onPaneDoubleClick={handlePaneDoubleClick}
          />

          {isDraggingFile ? (
            <div className="pointer-events-none absolute inset-4 z-40 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/10 text-sm font-medium text-primary backdrop-blur-sm">
              松开鼠标，把图片添加到画布
            </div>
          ) : null}
          <div className="pointer-events-none absolute left-6 top-6 z-20 rounded-lg border bg-background/70 px-3 py-2 text-xs text-muted-foreground shadow-sm backdrop-blur">
            左侧工具添加节点 · 空白双击打开添加菜单 · Ctrl+Z 撤销 · Delete 删除
          </div>
          {!project ? (
            <div className="absolute inset-0 z-30 flex items-center justify-center bg-background/40 backdrop-blur-[1px]">
              <Button className="gap-2" onClick={createProject} disabled={creatingProject}>
                {creatingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
                创建画布开始
              </Button>
            </div>
          ) : null}

          {addMenu ? (
            <div
              data-canvas-menu
              className="absolute z-50 w-56 rounded-lg border bg-popover p-1 shadow-lg"
              style={{ left: addMenu.left, top: addMenu.top }}
            >
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('text', addMenu.x, addMenu.y)}>
                <Type className="h-4 w-4" /> 文本
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('text2img', addMenu.x, addMenu.y)}>
                <Brush className="h-4 w-4" /> 文生图
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('img2img', addMenu.x, addMenu.y)}>
                <FileImage className="h-4 w-4" /> 图生图
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => fileInputRef.current?.click()}>
                <ImageIcon className="h-4 w-4" /> 上传图片
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('layeredImage', addMenu.x, addMenu.y)}>
                <Layers className="h-4 w-4" /> 图层图片
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('frame', addMenu.x, addMenu.y)}>
                <Maximize2 className="h-4 w-4" /> 分组框
              </button>
              <div className="my-1 h-px bg-border" />
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('text2img', { x: addMenu.x, y: addMenu.y })}>
                <Brush className="h-4 w-4" /> 文生图流程
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('img2img', { x: addMenu.x, y: addMenu.y })}>
                <FileImage className="h-4 w-4" /> 图生图流程
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('image-chain', { x: addMenu.x, y: addMenu.y })}>
                <Wand2 className="h-4 w-4" /> 多轮精修流程
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('layered', { x: addMenu.x, y: addMenu.y })}>
                <Layers className="h-4 w-4" /> 图层 PSD 流程
              </button>
            </div>
          ) : null}

          {contextMenu ? (
            <div
              data-canvas-menu
              className="absolute z-50 w-56 rounded-lg border bg-popover p-1 shadow-lg"
              style={{ left: contextMenu.left, top: contextMenu.top }}
            >
              <button
                className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
                disabled={selectedNodeIds.length === 0 && !selectedNodeId}
                onClick={() => {
                  copySelectedNodes();
                  setContextMenu(null);
                }}
              >
                <Copy className="h-4 w-4" /> 复制节点
              </button>
              <button
                className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
                disabled={selectedNodeIds.length === 0 && !selectedNodeId}
                onClick={cutSelectedNodes}
              >
                <Copy className="h-4 w-4" /> 剪切节点
              </button>
              <button
                className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
                disabled={!clipboard}
                onClick={() => pasteClipboardNodes({ x: contextMenu.x, y: contextMenu.y })}
              >
                <Plus className="h-4 w-4" /> 粘贴到这里
              </button>
              {contextMenuNodeHasOutgoing ? (
                <button
                  className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted"
                  onClick={() => {
                    if (contextMenuNode) void runDownstream(contextMenuNode);
                    setContextMenu(null);
                  }}
                >
                  <Wand2 className="h-4 w-4" /> 运行下游
                </button>
              ) : null}
              <button
                className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
                disabled={selectedNodes.length < 2}
                onClick={() => {
                  createFrameAroundSelection();
                  setContextMenu(null);
                }}
              >
                <Maximize2 className="h-4 w-4" /> 框住所选
              </button>
              <div className="my-1 h-px bg-border" />
              <button
                className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-destructive hover:bg-muted"
                onClick={() => {
                  removeSelectedNode();
                  setContextMenu(null);
                }}
              >
                <Trash2 className="h-4 w-4" /> 删除所选
              </button>
            </div>
          ) : null}
        </div>

      </section>

      {(selectedNode || selectedNodes.length > 1) ? (
      <aside className="absolute bottom-4 right-4 top-20 z-30 hidden w-80 overflow-y-auto rounded-lg border bg-background/95 p-4 shadow-xl backdrop-blur lg:block">
        {selectedNodes.length > 1 ? (
          <div className="space-y-4">
            <div className="rounded-lg border bg-muted/20 p-3">
              <div className="text-sm font-medium">已选择 {selectedNodes.length} 个模块</div>
              <div className="mt-1 text-xs text-muted-foreground">
                {selectedBounds ? `${Math.round(selectedBounds.width)} × ${Math.round(selectedBounds.height)}` : '批量编辑'}
              </div>
            </div>

            <div className="rounded-lg border p-3">
              <Label>对齐</Label>
              <div className="mt-2 grid grid-cols-3 gap-2">
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('left')}>左</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('center')}>中</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('right')}>右</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('top')}>上</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('middle')}>中线</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('bottom')}>下</Button>
              </div>
            </div>

            <div className="rounded-lg border p-3">
              <Label>分布与整理</Label>
              <div className="mt-2 grid grid-cols-2 gap-2">
                <Button variant="outline" size="sm" onClick={() => distributeSelectedNodes('x')} disabled={selectedNodes.length < 3}>横向分布</Button>
                <Button variant="outline" size="sm" onClick={() => distributeSelectedNodes('y')} disabled={selectedNodes.length < 3}>纵向分布</Button>
              </div>
              <Button variant="outline" className="mt-2 w-full gap-2" onClick={createFrameAroundSelection}>
                <Maximize2 className="h-4 w-4" /> 创建分组框
              </Button>
            </div>

            <div className="grid grid-cols-2 gap-2">
              <Button variant="outline" className="gap-2" onClick={duplicateSelectedNode}>
                <Copy className="h-4 w-4" /> 复制
              </Button>
              <Button variant="destructive" className="gap-2" onClick={removeSelectedNode}>
                <Trash2 className="h-4 w-4" /> 删除
              </Button>
            </div>
          </div>
        ) : !selectedNode ? (
          <div className="flex h-full flex-col items-center justify-center text-center text-sm text-muted-foreground">
            <StickyNote className="mb-3 h-10 w-10 opacity-40" />
            点击画布模块后编辑参数
          </div>
        ) : (
          <div className="space-y-4">
            <div className="rounded-lg border bg-muted/20 p-3">
              <div className="mb-2 flex items-center justify-between">
                <div className="flex items-center gap-2 text-sm font-medium">
                  <Link2 className="h-4 w-4" /> 节点连线
                </div>
                {connectingFromId === selectedNode.id ? <Badge variant="outline">选择目标</Badge> : null}
              </div>
              <div className="space-y-2 text-xs text-muted-foreground">
                <Button
                  type="button"
                  variant={connectingFromId === selectedNode.id ? 'default' : 'outline'}
                  size="sm"
                  className="w-full gap-2"
                  onClick={() => handleConnectorClick(selectedNode.id, 'output')}
                >
                  <Link2 className="h-4 w-4" />
                  {connectingFromId === selectedNode.id ? '取消连线' : '从此模块开始连线'}
                </Button>
                {selectedNodeConnections.length === 0 ? (
                  <div className="rounded-md border border-dashed p-2 text-center">暂无连线</div>
                ) : (
                  selectedNodeConnections.map(connection => {
                    const source = state.nodes.find(node => node.id === connection.sourceNodeId);
                    const target = state.nodes.find(node => node.id === connection.targetNodeId);
                    return (
                      <div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border bg-background px-2 py-1.5">
                        <span className="min-w-0 truncate">
                          {source?.title || '未知模块'} → {target?.title || '未知模块'}
                        </span>
                        <button className="text-destructive hover:underline" onClick={() => removeConnection(connection.id)}>删除</button>
                      </div>
                    );
                  })
                )}
              </div>
            </div>

            <div>
              <div className="mb-1 flex items-center justify-between">
                <Label>模块标题</Label>
                <div className="flex gap-1">
                  <Button variant="outline" size="icon" className="h-7 w-7" onClick={duplicateSelectedNode} title="复制模块">
                    <Copy className="h-3.5 w-3.5" />
                  </Button>
                  <Button variant="outline" size="icon" className="h-7 w-7 text-destructive" onClick={removeSelectedNode} title="删除模块">
                    <Trash2 className="h-3.5 w-3.5" />
                  </Button>
                </div>
              </div>
              <Input value={selectedNode.title} onChange={(event) => updateNode(selectedNode.id, { title: event.target.value })} />
            </div>

            {selectedNode.type === 'text' ? (
              <div>
                <Label>文本内容</Label>
                <Textarea className="min-h-40" value={selectedNode.text || ''} onChange={(event) => updateNode(selectedNode.id, { text: event.target.value })} />
              </div>
            ) : null}

            {selectedNode.type === 'image' ? (
              <div className="space-y-2">
                <Label>图片地址</Label>
                <Input value={selectedNode.imageUrl || ''} onChange={(event) => updateNode(selectedNode.id, { imageUrl: event.target.value })} />
                {getNodeImageUrl(selectedNode) ? (
                  <div className="grid grid-cols-2 gap-2">
                    <Button
                      variant="outline"
                      className="gap-2"
                      onClick={() => setPreviewImage({ src: getNodeImageUrl(selectedNode), title: selectedNode.title })}
                    >
                      <Eye className="h-4 w-4" /> 预览
                    </Button>
                    <Button
                      variant="outline"
                      className="gap-2"
                      onClick={() => void downloadImageFromNode(selectedNode)}
                    >
                      <Download className="h-4 w-4" /> 下载
                    </Button>
                  </div>
                ) : null}
                {getNodeImageUrl(selectedNode) ? (
                  <Button
                    variant="outline"
                    className="w-full gap-2"
                    onClick={() => createVariationNodeFromCanvas(selectedNode.id)}
                  >
                    <Wand2 className="h-4 w-4" /> 用作图生图参考
                  </Button>
                ) : null}
              </div>
            ) : null}

            {selectedNode.type === 'frame' ? (
              <div className="space-y-3">
                <div>
                  <Label>说明文字</Label>
                  <Textarea className="min-h-24" value={selectedNode.text || ''} onChange={(event) => updateNode(selectedNode.id, { text: event.target.value })} />
                </div>
                <div>
                  <Label>边框颜色</Label>
                  <Input value={selectedNode.color || '#7c3aed'} onChange={(event) => updateNode(selectedNode.id, { color: event.target.value })} />
                </div>
              </div>
            ) : null}

            {selectedNode.type === 'text' ? (
              <div>
                <Label>多模态模型</Label>
                <Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
                  <SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
                  <SelectContent>
                    {textModelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
                  </SelectContent>
                </Select>
              </div>
            ) : null}

            {selectedNodeCanGenerate ? (
              <>
                <div>
                  <Label>生成模型</Label>
                  <Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
                    <SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
                    <SelectContent>
                      {selectedNodeModelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
                    </SelectContent>
                  </Select>
                </div>

                <div>
                  <Label>{selectedNode.type === 'image' ? '节点补充描述' : '创作描述'}</Label>
                  <Textarea className="min-h-32" value={selectedNode.prompt || ''} onChange={(event) => updateNode(selectedNode.id, { prompt: event.target.value })} />
                </div>

                <div>
                  <Label>负面提示词</Label>
                  <Textarea className="min-h-20" value={selectedNode.negativePrompt || ''} onChange={(event) => updateNode(selectedNode.id, { negativePrompt: event.target.value })} />
                </div>

                <div className="grid grid-cols-2 gap-3">
                  <div>
                    <Label>比例</Label>
                    <Select value={selectedNode.params?.aspectRatio || (selectedNode.type === 'text2img' ? '1:1' : selectedNodeHasIncomingImage || selectedNode.referenceImage ? 'original' : '1:1')} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, aspectRatio: value } })}>
                      <SelectTrigger><SelectValue /></SelectTrigger>
                      <SelectContent>
                        {selectedNodeAspectOptions.map(item => (
                          <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                  <div>
                    <Label>分辨率</Label>
                    <Select value={selectedNode.params?.resolution || '2K'} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, resolution: value } })}>
                      <SelectTrigger><SelectValue /></SelectTrigger>
                      <SelectContent>
                        {RESOLUTION_OPTIONS.map(item => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
                      </SelectContent>
                    </Select>
                  </div>
                </div>

                {selectedNode.type === 'img2img' || selectedNode.type === 'image' ? (
                  <div className="space-y-2">
                    <Label>画布参考图</Label>
                    <Select value={selectedNode.referenceImage || ''} onValueChange={(value) => updateNode(selectedNode.id, { referenceImage: value })}>
                      <SelectTrigger><SelectValue placeholder="选择画布图片" /></SelectTrigger>
                      <SelectContent>
                        {state.nodes.filter(node => node.id !== selectedNode.id && node.type === 'image' && node.imageUrl).map(node => (
                          <SelectItem key={node.id} value={node.imageUrl || ''}>{node.title}</SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                ) : null}

                <Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || imageModelOptions.length === 0}>
                  {selectedNode.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
                  生成
                </Button>
                {state.connections.some(connection => connection.sourceNodeId === selectedNode.id) ? (
                  <Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || imageModelOptions.length === 0}>
                    <Wand2 className="h-4 w-4" /> 运行本模块及下游
                  </Button>
                ) : null}
              </>
            ) : null}

            {selectedNode.type === 'layeredImage' ? (
              <div className="space-y-3">
                <div className="grid grid-cols-3 gap-2">
                  <Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'element')}>元素</Button>
                  <Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'text')}>文字</Button>
                  <Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'icon')}>图标</Button>
                </div>

                <div className="space-y-2">
                  {(selectedNode.layers || []).map((layer, index) => (
                    <div key={layer.id} className="rounded-lg border p-3">
                      <div className="mb-2 flex items-center justify-between gap-2">
                        <Input
                          className="h-8 min-w-0"
                          value={layer.name}
                          disabled={layer.locked}
                          onChange={(event) => updateLayer(selectedNode.id, layer.id, { name: event.target.value })}
                        />
                        <Badge variant="outline">{layer.type}</Badge>
                      </div>
                      <div className="mb-2 flex gap-1">
                        <Button variant="outline" size="sm" onClick={() => updateLayer(selectedNode.id, layer.id, { visible: !layer.visible })}>
                          {layer.visible ? '隐藏' : '显示'}
                        </Button>
                        <Button variant="outline" size="sm" onClick={() => updateLayer(selectedNode.id, layer.id, { locked: !layer.locked })}>
                          {layer.locked ? '解锁' : '锁定'}
                        </Button>
                        <Button variant="outline" size="sm" disabled={index === 0} onClick={() => moveLayer(selectedNode.id, layer.id, -1)}>上移</Button>
                        <Button variant="outline" size="sm" disabled={index === (selectedNode.layers || []).length - 1} onClick={() => moveLayer(selectedNode.id, layer.id, 1)}>下移</Button>
                      </div>
                      <div className="grid grid-cols-4 gap-2">
                        <div>
                          <Label className="text-[11px]">X</Label>
                          <Input className="h-8" type="number" value={layer.x} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { x: Number(event.target.value) || 0 })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">Y</Label>
                          <Input className="h-8" type="number" value={layer.y} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { y: Number(event.target.value) || 0 })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">宽</Label>
                          <Input className="h-8" type="number" value={layer.width} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { width: Math.max(1, Number(event.target.value) || 1) })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">高</Label>
                          <Input className="h-8" type="number" value={layer.height} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { height: Math.max(1, Number(event.target.value) || 1) })} />
                        </div>
                      </div>
                      <div className="mt-2 grid grid-cols-[1fr_88px] gap-2">
                        <div>
                          <Label className="text-[11px]">颜色</Label>
                          <Input className="h-8" value={layer.color || LAYER_COLORS[layer.type]} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { color: event.target.value })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">透明度</Label>
                          <Input className="h-8" type="number" min="0" max="1" step="0.1" value={layer.opacity ?? 1} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { opacity: Math.min(1, Math.max(0, Number(event.target.value))) })} />
                        </div>
                      </div>
                      {layer.type === 'text' ? (
                        <div className="mt-2">
                          <Label className="text-[11px]">文字内容</Label>
                          <Textarea className="min-h-16" value={layer.text || ''} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { text: event.target.value })} />
                        </div>
                      ) : null}
                      <Button variant="ghost" size="sm" className="mt-2 w-full text-destructive" onClick={() => removeLayer(selectedNode.id, layer.id)}>
                        删除图层
                      </Button>
                    </div>
                  ))}
                </div>

                <div className="grid grid-cols-3 gap-2">
                  <Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'png')}>
                    <Download className="h-3 w-3" /> PNG
                  </Button>
                  <Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'json')}>
                    <Download className="h-3 w-3" /> 图层包
                  </Button>
                  <Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'psd')}>
                    <Download className="h-3 w-3" /> PSD
                  </Button>
                </div>
              </div>
            ) : null}

            <Button variant="destructive" className="w-full gap-2" onClick={removeSelectedNode}>
              <Trash2 className="h-4 w-4" /> 删除模块
            </Button>
          </div>
        )}
      </aside>
      ) : null}
      {previewImage ? (
        <div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-6" onClick={() => setPreviewImage(null)}>
          <div className="absolute right-5 top-5 flex items-center gap-2">
            <Button
              variant="secondary"
              size="sm"
              className="gap-2"
              onClick={(event) => {
                event.stopPropagation();
                const sourceNode = state.nodes.find(node => getNodeImageUrl(node) === previewImage.src);
                if (sourceNode) void downloadImageFromNode(sourceNode);
              }}
            >
              <Download className="h-4 w-4" /> 下载
            </Button>
            <Button variant="secondary" size="icon" onClick={() => setPreviewImage(null)} title="关闭预览">
              <X className="h-4 w-4" />
            </Button>
          </div>
          <div className="max-h-full max-w-full" onClick={(event) => event.stopPropagation()}>
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src={previewImage.src} alt={previewImage.title} className="max-h-[88vh] max-w-[92vw] object-contain" />
          </div>
        </div>
      ) : null}
    </div>
  );
}
