morsoli/aimangastudio/main 84k tokens More Tools
```
├── .claude/
   ├── settings.local.json
├── .gitignore (100 tokens)
├── AI 剧情建议.jpg
├── App.tsx (9.1k tokens)
├── README.en-US.md (300 tokens)
├── README.ja-JP.md (200 tokens)
├── README.md (300 tokens)
├── components/
   ├── ApiKeyModal.tsx (500 tokens)
   ├── CharacterGenerationModal.tsx (4.2k tokens)
   ├── CharacterUploader.tsx (500 tokens)
   ├── ComparisonViewer.tsx (900 tokens)
   ├── GenerationControls.tsx (1100 tokens)
   ├── Header.tsx (4.8k tokens)
   ├── MangaViewerModal.tsx (900 tokens)
   ├── MaskingModal.tsx (1600 tokens)
   ├── PanelEditor.tsx (16.8k tokens)
   ├── PoseEditorModal.tsx (3.5k tokens)
   ├── ResultDisplay.tsx (2.9k tokens)
   ├── StoryInput.tsx (300 tokens)
   ├── StorySuggestionModal.tsx (1200 tokens)
   ├── VideoProducer.tsx (6.6k tokens)
   ├── WorldviewModal.tsx (1100 tokens)
   ├── icons.tsx (3.8k tokens)
├── contexts/
   ├── LocalizationContext.tsx (200 tokens)
├── hooks/
   ├── useApiKey.ts (200 tokens)
   ├── useLocalization.ts (100 tokens)
├── i18n/
   ├── locales.ts (5k tokens)
├── index.html (400 tokens)
├── index.tsx (100 tokens)
├── logo.svg (2.7k tokens)
├── metadata.json
├── og.webp
├── package-lock.json (omitted)
├── package.json (100 tokens)
├── services/
   ├── geminiService.ts (5.6k tokens)
   ├── videoGeminiService.ts (4.6k tokens)
├── tsconfig.json (100 tokens)
├── types.ts (1000 tokens)
├── vite.config.ts (100 tokens)
├── 创建角色.jpg
├── 批量导出.jpg
├── 风格设定.jpg
```


## /.claude/settings.local.json

```json path="/.claude/settings.local.json" 
{
  "permissions": {
    "allow": [
      "Bash(find:*)"
    ],
    "deny": [],
    "ask": []
  }
}
```

## /.gitignore

```gitignore path="/.gitignore" 
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

```

## /AI 剧情建议.jpg

Binary file available at https://raw.githubusercontent.com/morsoli/aimangastudio/refs/heads/main/AI 剧情建议.jpg

## /App.tsx

```tsx path="/App.tsx" 
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Header } from './components/Header';
import { ApiKeyModal } from './components/ApiKeyModal';
import { PanelEditor } from './components/PanelEditor';
import { GenerationControls } from './components/GenerationControls';
import { ResultDisplay } from './components/ResultDisplay';
import { MaskingModal } from './components/MaskingModal';
import { CharacterGenerationModal } from './components/CharacterGenerationModal';
import { ComparisonViewer } from './components/ComparisonViewer';
import { MangaViewerModal } from './components/MangaViewerModal';
import { WorldviewModal } from './components/WorldviewModal';
import { StorySuggestionModal } from './components/StorySuggestionModal';
import { VideoProducer } from './components/VideoProducer';
import { generateMangaPage, generateCharacterSheet, editCharacterSheet, colorizeMangaPage, editMangaPage, generateDetailedStorySuggestion, generateLayoutProposal, analyzeAndSuggestCorrections } from './services/geminiService';
import type { Character, Page, CanvasShape, ViewTransform, StorySuggestion, PanelShape, ImageShape, AnalysisResult } from './types';
import { AddUserIcon, TrashIcon, LinkIcon } from './components/icons';
import { useLocalization } from './hooks/useLocalization';
import { Language } from './i18n/locales';
import { useApiKey } from './hooks/useApiKey';

const createInitialSkeleton = (x: number, y: number, width: number, height: number) => {
    const centerX = x + width / 2;
    const topY = y + height * 0.15;
    const hipY = y + height * 0.5;
    const armY = y + height * 0.3;
    const legY = y + height * 0.9;
    const shoulderWidth = width * 0.2;
    const hipWidth = width * 0.15;
    const eyeY = topY - height * 0.03;
    const eyeDistX = width * 0.07;
    const noseY = topY;
    const mouthY = topY + height * 0.05;
    return {
        head: { x: centerX, y: topY }, neck: { x: centerX, y: armY },
        leftShoulder: { x: centerX - shoulderWidth, y: armY }, rightShoulder: { x: centerX + shoulderWidth, y: armY },
        leftElbow: { x: centerX - shoulderWidth * 1.5, y: hipY }, rightElbow: { x: centerX + shoulderWidth * 1.5, y: hipY },
        leftHand: { x: centerX - shoulderWidth * 1.2, y: legY - height * 0.1 }, rightHand: { x: centerX + shoulderWidth * 1.2, y: legY - height * 0.1 },
        hips: { x: centerX, y: hipY }, leftHip: { x: centerX - hipWidth, y: hipY }, rightHip: { x: centerX + hipWidth, y: hipY },
        leftKnee: { x: centerX - hipWidth, y: hipY + height * 0.2 }, rightKnee: { x: centerX + hipWidth, y: hipY + 0.2 },
        leftFoot: { x: centerX - hipWidth, y: legY }, rightFoot: { x: centerX + hipWidth, y: legY },
        leftEye: { x: centerX - eyeDistX, y: eyeY }, rightEye: { x: centerX + eyeDistX, y: eyeY },
        nose: { x: centerX, y: noseY }, mouth: { x: centerX, y: mouthY },
    };
};

const initialPage: Omit<Page, 'id' | 'name'> = {
  shapes: [],
  shapesHistory: [[]],
  shapesHistoryIndex: 0,
  panelLayoutImage: null,
  sceneDescription: '',
  panelCharacterMap: {},
  generatedImage: null,
  generatedText: null,
  generatedColorMode: null,
  aspectRatio: 'A4',
  viewTransform: { scale: 1, x: 0, y: 0 },
  shouldReferencePrevious: false,
  assistantProposalImage: null,
  proposalOpacity: 0.5,
  isProposalVisible: true,
  proposedShapes: null,
};

const aspectRatios: { [key: string]: { name: string, value: string, w: number, h: number } } = {
    'A4': { name: 'A4', value: '210:297', w: 595, h: 842 },
    '竖版': { name: '竖版', value: '3:4', w: 600, h: 800 },
    '正方形': { name: '正方形', value: '1:1', w: 800, h: 800 },
    '横版': { name: '横版', value: '16:9', w: 1280, h: 720 }
};


export default function App(): React.ReactElement {
  const { t, language, setLanguage } = useLocalization();
  const { apiKey, isApiKeyModalOpen, setIsApiKeyModalOpen, saveApiKey, clearApiKey, hasApiKey } = useApiKey();
  
  const [pages, setPages] = useState<Page[]>([{...initialPage, id: Date.now().toString(), name: `${t('pages')} 1` }]);
  const [currentPageId, setCurrentPageId] = useState<string>(pages[0].id);
  const [isSidebarOpen, setIsSidebarOpen] = useState(true);
  const [characters, setCharacters] = useState<Character[]>([]);
  const [showCharacterModal, setShowCharacterModal] = useState<boolean>(false);
  const [colorMode, setColorMode] = useState<'color' | 'monochrome'>('monochrome');
  
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isColoring, setIsColoring] = useState<boolean>(false);
  const [isSuggestingStory, setIsSuggestingStory] = useState<boolean>(false);
  const [isSuggestingLayout, setIsSuggestingLayout] = useState<boolean>(false);
  const [isAnalyzing, setIsAnalyzing] = useState(false);
  const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null);

  const [error, setError] = useState<string | null>(null);
  const [isAspectRatioOpen, setIsAspectRatioOpen] = useState(false);
  const [isDraggingCharacter, setIsDraggingCharacter] = useState(false);
  const [showMangaViewer, setShowMangaViewer] = useState(false);
  const [isMasking, setIsMasking] = useState(false);
  const [currentMask, setCurrentMask] = useState<string | null>(null);
  const [viewMode, setViewMode] = useState<'editor' | 'result'>('editor');
  const [isFullscreen, setIsFullscreen] = useState(false);
  
  const [worldview, setWorldview] = useState<string>('');
  const [showWorldviewModal, setShowWorldviewModal] = useState<boolean>(false);
  const [showStorySuggestionModal, setShowStorySuggestionModal] = useState<boolean>(false);
  const [storySuggestion, setStorySuggestion] = useState<StorySuggestion | null>(null);
  const [generateEmptyBubbles, setGenerateEmptyBubbles] = useState<boolean>(false);

  const [assistantModeState, setAssistantModeState] = useState<{
    isActive: boolean;
    totalPages: number;
    currentPageNumber: number;
    statusMessage: string;
    hasError?: boolean;
    failedPageNumber?: number;
  } | null>(null);

  const editorAreaRef = useRef<HTMLDivElement>(null);
  const stopAutoGenerationRef = useRef(false);

  const [currentView, setCurrentView] = useState<'manga-editor' | 'video-producer'>('manga-editor');

  const toggleFullscreen = useCallback(() => {
    const elem = editorAreaRef.current;
    if (!elem) return;
    if (!document.fullscreenElement) {
        elem.requestFullscreen().catch(err => {
            alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
        });
    } else {
        document.exitFullscreen();
    }
  }, []);

  useEffect(() => {
      const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
      document.addEventListener('fullscreenchange', onFullscreenChange);
      return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
  }, []);
  const panelEditorRef = useRef<{ getLayoutAsImage: (includeCharacters: boolean, characters: Character[]) => Promise<string> }>(null);

  const currentPage = useMemo(() => pages.find(p => p.id === currentPageId) || pages[0], [pages, currentPageId]);
  
  useEffect(() => {
    setViewMode(currentPage.generatedImage ? 'result' : 'editor');
    setAnalysisResult(null); // Clear analysis when page changes
  }, [currentPage.id, currentPage.generatedImage]);


  const handleUpdateCurrentPage = useCallback((updates: Partial<Page>) => {
    setPages(prevPages => prevPages.map(p => 
      p.id === currentPageId ? { ...p, ...updates } : p
    ));
  }, [currentPageId]);

  const handleViewTransformChange = useCallback((vt: ViewTransform) => {
    handleUpdateCurrentPage({ viewTransform: vt });
  }, [handleUpdateCurrentPage]);

  const handleShapesChange = useCallback((newShapes: CanvasShape[], recordHistory: boolean = true) => {
    setPages(prevPages => prevPages.map(p => {
        if (p.id !== currentPageId) return p;

        let updatedSceneDescription = p.sceneDescription;
        const newPanelCount = newShapes.filter(s => s.type === 'panel').length;
        const oldPanelCount = p.shapes.filter(s => s.type === 'panel').length;

        if (newPanelCount !== oldPanelCount) {
            const existingPanels: Record<string, string> = {};
            const panelRegex = /Panel (\d+):([\s\S]*?)(?=\n\nPanel \d+:|$)/g;
            let match;
            while ((match = panelRegex.exec(p.sceneDescription)) !== null) {
                existingPanels[match[1]] = match[2].trim();
            }

            if (newPanelCount > 0) {
                let newDesc = '';
                for (let i = 1; i <= newPanelCount; i++) {
                    newDesc += `Panel ${i}: ${existingPanels[i] || ''}\n\n`;
                }
                updatedSceneDescription = newDesc.trim();
            } else {
                updatedSceneDescription = '';
            }
        }

        if (recordHistory) {
            const newHistory = p.shapesHistory.slice(0, p.shapesHistoryIndex + 1);
            newHistory.push(newShapes);
            return { 
                ...p, 
                shapes: newShapes,
                shapesHistory: newHistory,
                shapesHistoryIndex: newHistory.length - 1,
                sceneDescription: updatedSceneDescription,
            };
        } else {
             const newHistory = [...p.shapesHistory];
             newHistory[p.shapesHistoryIndex] = newShapes;
             return { ...p, shapes: newShapes, shapesHistory: newHistory, sceneDescription: updatedSceneDescription };
        }
    }));
  }, [currentPageId]);

  const handleUndo = useCallback(() => {
    setPages(prevPages => prevPages.map(p => {
        if (p.id !== currentPageId || p.shapesHistoryIndex <= 0) return p;
        const newIndex = p.shapesHistoryIndex - 1;
        return {
            ...p,
            shapes: p.shapesHistory[newIndex],
            shapesHistoryIndex: newIndex,
        };
    }));
  }, [currentPageId]);

  const handleRedo = useCallback(() => {
     setPages(prevPages => prevPages.map(p => {
        if (p.id !== currentPageId || p.shapesHistoryIndex >= p.shapesHistory.length - 1) return p;
        const newIndex = p.shapesHistoryIndex + 1;
        return {
            ...p,
            shapes: p.shapesHistory[newIndex],
            shapesHistoryIndex: newIndex,
        };
    }));
  }, [currentPageId]);

  const handleGenerateImage = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
        let panelLayoutImage = currentPage.panelLayoutImage;
        const pageUpdates: Partial<Page> = {};

        if (!panelLayoutImage || viewMode === 'editor') {
            if (!panelEditorRef.current) {
                setError("Editor is not ready.");
                setIsLoading(false);
                return;
            }
            panelLayoutImage = await panelEditorRef.current.getLayoutAsImage(true, characters);
            pageUpdates.panelLayoutImage = panelLayoutImage;
        }
        if (!panelLayoutImage) {
            setError("Failed to capture panel layout.");
            setIsLoading(false);
            return;
        }
        
        const characterIdsInScene = new Set(currentPage.shapes.filter(s => s.type === 'image').map(s => (s as ImageShape).characterId));
        const relevantCharacters = characters.filter(c => characterIdsInScene.has(c.id));
        
        let previousPageData: Pick<Page, 'generatedImage' | 'sceneDescription'> | undefined = undefined;
        if (currentPage.shouldReferencePrevious) {
            const currentPageIndex = pages.findIndex(p => p.id === currentPageId);
            if (currentPageIndex > 0) {
                const prevPage = pages[currentPageIndex - 1];
                if (prevPage.generatedImage) {
                    previousPageData = {
                        generatedImage: prevPage.generatedImage,
                        sceneDescription: prevPage.sceneDescription
                    };
                }
            }
        }
        
        const result = await generateMangaPage(relevantCharacters, panelLayoutImage, currentPage.sceneDescription, colorMode, previousPageData, generateEmptyBubbles);
        pageUpdates.generatedImage = result.image;
        pageUpdates.generatedText = result.text;
        pageUpdates.generatedColorMode = colorMode;

        handleUpdateCurrentPage(pageUpdates);
        setCurrentMask(null);
        setAnalysisResult(null);
        setViewMode('result');
    } catch (e: unknown) {
        setError(e instanceof Error ? `Generation failed: ${e.message}` : "An unknown error occurred.");
    } finally {
        setIsLoading(false);
    }
  }, [currentPage, pages, currentPageId, characters, colorMode, handleUpdateCurrentPage, viewMode, generateEmptyBubbles]);

  const handleColorize = useCallback(async () => {
      if (!currentPage.generatedImage) {
          setError("No generated image to colorize.");
          return;
      }
      setIsColoring(true);
      setError(null);
      try {
          const characterIdsInScene = new Set(currentPage.shapes.filter(s => s.type === 'image').map(s => (s as ImageShape).characterId));
          const relevantCharacters = characters.filter(c => characterIdsInScene.has(c.id));
          
          const coloredImage = await colorizeMangaPage(currentPage.generatedImage, relevantCharacters);
          handleUpdateCurrentPage({ generatedImage: coloredImage, generatedColorMode: 'color' });
          setAnalysisResult(null);
      } catch (e: unknown) {
          setError(e instanceof Error ? `Colorization failed: ${e.message}` : "An unknown error occurred.");
      } finally {
          setIsColoring(false);
      }
  }, [currentPage, characters, handleUpdateCurrentPage]);

 const handleEditImage = useCallback(async (editPrompt: string, editReferenceImages: string[] | null) => {
    if (!currentPage.generatedImage) {
        setError("No generated image to edit.");
        return;
    }
    setIsLoading(true);
    setError(null);
    try {
        const editedImage = await editMangaPage(currentPage.generatedImage, editPrompt, currentMask || undefined, editReferenceImages || undefined);
        handleUpdateCurrentPage({ generatedImage: editedImage });
        setCurrentMask(null);
        setAnalysisResult(null);
    } catch (e: unknown) {
        setError(e instanceof Error ? `Editing failed: ${e.message}` : "An unknown error occurred during editing.");
    } finally {
        setIsLoading(false);
    }
  }, [currentPage.generatedImage, currentMask, handleUpdateCurrentPage]);

  const handleGenerateDetailedStory = async (premise: string, shouldContinue: boolean) => {
      setIsSuggestingStory(true);
      setError(null);
      setStorySuggestion(null);
      try {
          let previousPagesContext: Pick<Page, 'generatedImage' | 'sceneDescription'>[] | undefined = undefined;
          if (shouldContinue) {
              const currentPageIndex = pages.findIndex(p => p.id === currentPageId);
              if (currentPageIndex >= 0) {
                  const start = Math.max(0, currentPageIndex - 1);
                  previousPagesContext = pages.slice(start, currentPageIndex + 1)
                      .filter(p => p.generatedImage && p.sceneDescription)
                      .map(p => ({ generatedImage: p.generatedImage!, sceneDescription: p.sceneDescription }));
              }
          }
          const suggestion = await generateDetailedStorySuggestion(premise, worldview, characters, previousPagesContext);
          setStorySuggestion(suggestion);
      } catch (e) {
          setError(e instanceof Error ? `Story suggestion failed: ${e.message}` : "An unknown error occurred.");
      } finally {
          setIsSuggestingStory(false);
      }
  };

    const handleGenerateLayoutProposal = async () => {
        setIsSuggestingLayout(true);
        setError(null);
        handleUpdateCurrentPage({ proposedShapes: null, assistantProposalImage: null });

        let canvasImageForProposal: string;
        const hasShapes = currentPage.shapes.length > 0;

        if (hasShapes) {
            if (!panelEditorRef.current) {
                setError("Editor is not ready to capture the canvas.");
                setIsSuggestingLayout(false);
                return;
            }
            canvasImageForProposal = await panelEditorRef.current.getLayoutAsImage(true, characters);
        } else {
            const config = aspectRatios[currentPage.aspectRatio];
            const canvas = document.createElement('canvas');
            canvas.width = config.w;
            canvas.height = config.h;
            const ctx = canvas.getContext('2d');
            if (ctx) {
                ctx.fillStyle = 'white';
                ctx.fillRect(0, 0, canvas.width, canvas.height);
            }
            canvasImageForProposal = canvas.toDataURL('image/png');
        }

        const currentPageIndex = pages.findIndex(p => p.id === currentPageId);
        let previousPageLayout: { proposalImage: string, sceneDescription: string } | undefined = undefined;
        if (currentPageIndex > 0) {
            const prevPage = pages[currentPageIndex - 1];
            if (prevPage.assistantProposalImage && prevPage.sceneDescription) {
                previousPageLayout = {
                    proposalImage: prevPage.assistantProposalImage,
                    sceneDescription: prevPage.sceneDescription
                };
            }
        }

        try {
            const { proposalImage } = await generateLayoutProposal(
                currentPage.sceneDescription,
                characters,
                currentPage.aspectRatio,
                previousPageLayout,
                canvasImageForProposal
            );
            handleUpdateCurrentPage({ assistantProposalImage: proposalImage, proposedShapes: null });
        } catch (e) {
            setError(e instanceof Error ? `Layout proposal failed: ${e.message}` : "An unknown error occurred.");
        } finally {
            setIsSuggestingLayout(false);
        }
    };
  
  const handleStartAutoGeneration = async (numPages: number, startFromPage: number = 1) => {
      setShowWorldviewModal(false);
      setError(null);
      if (characters.length === 0 && worldview === '') {
          setError(t('autoGenCharacterWarning'));
          return;
      }
      
      stopAutoGenerationRef.current = false;
      setAssistantModeState({ isActive: true, totalPages: numPages, currentPageNumber: startFromPage, statusMessage: t('autoGenStarting'), hasError: false });

      let currentLocalPages = [...pages];
      let localCurrentPageId: string;
      
      if (startFromPage > 1) {
        const retryPageIndex = startFromPage - 1;
        localCurrentPageId = currentLocalPages[retryPageIndex]?.id || currentLocalPages[currentLocalPages.length - 1].id;
      } else {
          const lastPage = currentLocalPages[currentLocalPages.length - 1];
          if (lastPage.assistantProposalImage || lastPage.shapes.length > 0 || lastPage.sceneDescription) {
              const newPageId = Date.now().toString();
              const newPage: Page = { ...initialPage, id: newPageId, name: `${t('pages')} ${currentLocalPages.length + 1}` };
              currentLocalPages = [...currentLocalPages, newPage];
              localCurrentPageId = newPageId;
          } else {
              localCurrentPageId = lastPage.id;
          }
      }
      
      setPages(currentLocalPages);
      setCurrentPageId(localCurrentPageId);
      
      let previousPageLayout: { proposalImage: string, sceneDescription: string } | undefined = undefined;
      const startIndex = startFromPage - 1;
      if (startIndex > 0 && currentLocalPages[startIndex - 1]) {
          const prevPage = currentLocalPages[startIndex - 1];
          if (prevPage.assistantProposalImage && prevPage.sceneDescription) {
              previousPageLayout = {
                  proposalImage: prevPage.assistantProposalImage,
                  sceneDescription: prevPage.sceneDescription
              };
          }
      }

      try {
        for (let i = startFromPage; i <= numPages; i++) {
          if (stopAutoGenerationRef.current) {
            setAssistantModeState(prevState => ({ ...prevState!, statusMessage: t('stopping') }));
            break;
          }
          const pageIndex = currentLocalPages.findIndex(p => p.id === localCurrentPageId);
          let pageObject = currentLocalPages[pageIndex];

          setAssistantModeState({ isActive: true, totalPages: numPages, currentPageNumber: i, statusMessage: t('autoGenStory', { current: i, total: numPages }) });
          
          let prevPageContext: Pick<Page, 'generatedImage' | 'sceneDescription'> | undefined;
          if (pageIndex > 0) {
              const prevPage = currentLocalPages[pageIndex - 1];
              if (prevPage.generatedImage && prevPage.sceneDescription) {
                  prevPageContext = { generatedImage: prevPage.generatedImage, sceneDescription: prevPage.sceneDescription };
              }
          }

          const storyPremise = `Generate the next part of the story for page ${i}.`;
          const story = await generateDetailedStorySuggestion(storyPremise, worldview, characters, prevPageContext ? [prevPageContext] : undefined);
          const sceneDescription = story.panels.map(p => `Panel ${p.panel}: ${p.description}${p.dialogue ? `\n${p.dialogue}` : ''}`).join('\n\n');
          
          pageObject = { ...pageObject, sceneDescription };
          currentLocalPages[pageIndex] = pageObject;
          setPages([...currentLocalPages]);
          
          if (stopAutoGenerationRef.current) break;

          setAssistantModeState({ isActive: true, totalPages: numPages, currentPageNumber: i, statusMessage: t('autoGenLayout', { current: i, total: numPages }) });
          const { proposalImage } = await generateLayoutProposal(sceneDescription, characters, pageObject.aspectRatio, previousPageLayout);
          
          previousPageLayout = { proposalImage, sceneDescription };
          
          pageObject = { 
            ...pageObject, 
            assistantProposalImage: proposalImage,
            proposedShapes: null, // No longer auto-applying shapes
          };
          currentLocalPages[pageIndex] = pageObject;
          setPages([...currentLocalPages]);
          
          if (i < numPages) {
              const nextPageId = Date.now().toString();
              const newPage: Page = { ...initialPage, id: nextPageId, name: `${t('pages')} ${currentLocalPages.length + 1}`, aspectRatio: pageObject.aspectRatio };
              currentLocalPages.push(newPage);
              localCurrentPageId = nextPageId;
              setPages(currentLocalPages);
              setCurrentPageId(localCurrentPageId);
          }
        }
      } catch (e: any) {
          const failedPageNumber = assistantModeState?.currentPageNumber || startFromPage;
          setAssistantModeState(prevState => ({
              ...(prevState!),
              isActive: true, 
              statusMessage: `Error on page ${failedPageNumber}: ${e.message}`,
              hasError: true,
              failedPageNumber: failedPageNumber,
          }));
          return;
      }
      
      setAssistantModeState(prevState => prevState ? {...prevState, statusMessage: t('autoGenComplete') || 'Generation Complete!', isActive: false} : null);
      setTimeout(() => setAssistantModeState(null), 2000);
      stopAutoGenerationRef.current = false;
      
  };

  const handleStopAutoGeneration = () => {
    stopAutoGenerationRef.current = true;
    setAssistantModeState(prevState => ({
        ...prevState!,
        statusMessage: t('stopping'),
    }));
  };

  const handleCharacterSave = (newCharacter: Omit<Character, 'id'>) => {
    setCharacters(prev => [...prev, { ...newCharacter, id: Date.now().toString() }]);
  };
  
  const handleDeleteCharacter = (idToDelete: string) => {
    setCharacters(prev => prev.filter(c => c.id !== idToDelete));
    setPages(prevPages => prevPages.map(page => ({
        ...page,
        shapes: page.shapes.filter(s => s.type !== 'image' || s.characterId !== idToDelete),
    })));
  };

  const handleAddPage = (switchToNewPage: boolean = true) => {
    const newPageId = Date.now().toString();
    const newPage: Page = {
      ...initialPage,
      id: newPageId,
      name: `${t('pages')} ${pages.length + 1}`,
      aspectRatio: currentPage.aspectRatio,
    };
    setPages(prev => [...prev, newPage]);
    if (switchToNewPage) {
        setCurrentPageId(newPageId);
    }
  };

  const handleDeletePage = (idToDelete: string) => {
      if (pages.length <= 1) return;
      setPages(prev => prev.filter(p => p.id !== idToDelete));
      if (currentPageId === idToDelete) {
          setCurrentPageId(pages.find(p => p.id !== idToDelete)!.id);
      }
  };
  
  const handleToggleReferencePrevious = (pageId: string) => {
    setPages(pages.map(p => p.id === pageId ? { ...p, shouldReferencePrevious: !p.shouldReferencePrevious } : p));
  };

  const handleAnalyzeResult = useCallback(async () => {
    if (!currentPage.panelLayoutImage || !currentPage.generatedImage) {
        setError(t('analysisError'));
        return;
    }
    setIsAnalyzing(true);
    setAnalysisResult(null);
    setError(null);
    try {
        const characterIdsInScene = new Set(currentPage.shapes.filter(s => s.type === 'image').map(s => (s as ImageShape).characterId));
        const relevantCharacters = characters.filter(c => characterIdsInScene.has(c.id));
        
        const result = await analyzeAndSuggestCorrections(
            currentPage.panelLayoutImage,
            currentPage.generatedImage,
            currentPage.sceneDescription,
            relevantCharacters
        );
        setAnalysisResult(result);
    } catch (e) {
        setError(e instanceof Error ? `Analysis failed: ${e.message}` : "An unknown error occurred during analysis.");
    } finally {
        setIsAnalyzing(false);
    }
  }, [currentPage, characters]);

  const handleApplyCorrection = useCallback(async () => {
    if (!analysisResult || !analysisResult.has_discrepancies || !analysisResult.correction_prompt) return;
    await handleEditImage(analysisResult.correction_prompt, null);
    setAnalysisResult(null);
  }, [analysisResult, handleEditImage]);
  
  const handleApplyLayout = useCallback(() => {
    if (!currentPage.assistantProposalImage) return;
    handleUpdateCurrentPage({
        generatedImage: currentPage.assistantProposalImage,
        panelLayoutImage: currentPage.assistantProposalImage,
        generatedColorMode: 'monochrome',
        shapes: [],
    });
    setViewMode('result');
  }, [currentPage.assistantProposalImage, handleUpdateCurrentPage]);


  const isReadyToGenerate = !!currentPage.sceneDescription;
  const isMonochromeResult = currentPage.generatedImage !== null && currentPage.generatedColorMode === 'monochrome';
  const anyLoading = isLoading || isColoring || isSuggestingLayout || isSuggestingStory || assistantModeState?.isActive || isAnalyzing;

  return (
    <div className="flex flex-col h-screen font-sans bg-gray-50 text-gray-800">
      <Header 
        isSidebarOpen={isSidebarOpen} 
        onToggleSidebar={() => setIsSidebarOpen(p => !p)}
        language={language}
        setLanguage={(lang) => setLanguage(lang as Language)}
  onOpenApiKeyModal={() => setIsApiKeyModalOpen(true)}
  hasApiKey={hasApiKey}
        onShowMangaViewer={() => setShowMangaViewer(true)}
        onShowWorldview={() => setShowWorldviewModal(true)}
        currentView={currentView}
        onSetView={setCurrentView}
      />
      <ApiKeyModal
        isOpen={isApiKeyModalOpen}
        onClose={() => setIsApiKeyModalOpen(false)}
        onSave={(key) => saveApiKey(key)}
      />
      {showCharacterModal && (
        <CharacterGenerationModal
          onClose={() => setShowCharacterModal(false)}
          onSave={handleCharacterSave}
          characters={characters}
        />
      )}
       {showWorldviewModal && (
        <WorldviewModal
          initialWorldview={worldview}
          onSave={(newWorldview) => {
            setWorldview(newWorldview);
            setShowWorldviewModal(false);
          }}
          onClose={() => setShowWorldviewModal(false)}
          onAutoGenerate={handleStartAutoGeneration}
          isGenerating={!!assistantModeState?.isActive}
          characters={characters}
        />
      )}
      {showStorySuggestionModal && (
        <StorySuggestionModal
          onClose={() => {
            setShowStorySuggestionModal(false);
            setStorySuggestion(null);
            setError(null);
          }}
          onGenerate={handleGenerateDetailedStory}
          isLoading={isSuggestingStory}
          suggestion={storySuggestion}
          onApply={(script) => {
            handleUpdateCurrentPage({ sceneDescription: script });
            setShowStorySuggestionModal(false);
            setStorySuggestion(null);
          }}
        />
      )}
      {showMangaViewer && (
        <MangaViewerModal 
            pages={pages}
            onClose={() => setShowMangaViewer(false)}
        />
      )}
      {isMasking && currentPage.generatedImage && (
        <MaskingModal
            baseImage={currentPage.generatedImage}
            onClose={() => setIsMasking(false)}
            onSave={(maskDataUrl) => {
                setCurrentMask(maskDataUrl);
                setIsMasking(false);
            }}
        />
      )}
      <div className="flex flex-1 overflow-hidden">
        {currentView === 'video-producer' ? (
          <VideoProducer characters={characters} pages={pages} />
        ) : (
          <>
            <div ref={editorAreaRef} className="flex flex-1 bg-gray-50">
              <aside className={`w-64 bg-white p-4 border-r border-gray-200 flex-col gap-8 flex-shrink-0 transition-all duration-300 ease-in-out ${isSidebarOpen ? 'flex' : 'hidden'}`}>
                <div>
                  <h3 className="font-bold text-sm mb-2 text-gray-500 tracking-wider uppercase">{t('pages')}</h3>
                  <div className="mb-4 relative">
                      <label htmlFor="aspect-ratio-select" className="block text-xs font-medium text-gray-500 mb-1">{t('aspectRatio')}</label>
                      <button
                          onClick={() => setIsAspectRatioOpen(prev => !prev)}
                          className="w-full text-sm p-1.5 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 flex justify-between items-center bg-white"
                      >
                          <span>{aspectRatios[currentPage.aspectRatio].name} ({aspectRatios[currentPage.aspectRatio].value})</span>
                          <svg className={`w-4 h-4 text-gray-500 transition-transform ${isAspectRatioOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg>
                      </button>
                      {isAspectRatioOpen && (
                          <div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-20">
                              {Object.entries(aspectRatios).map(([key, {name, value, w, h}]) => (
                                  <div key={key} onClick={() => { handleUpdateCurrentPage({ aspectRatio: key }); setIsAspectRatioOpen(false); }} className="px-3 py-2 text-sm hover:bg-indigo-50 cursor-pointer flex items-center gap-3">
                                      <div className="w-6 h-6 flex items-center justify-center">
                                          <div className="bg-gray-200 border border-gray-400" style={{ width: `${w/Math.max(w,h)*20}px`, height: `${h/Math.max(w,h)*20}px`}}></div>
                                      </div>
                                      <span>{name} ({value})</span>
                                  </div>
                              ))}
                          </div>
                      )}
                  </div>
                  {assistantModeState?.isActive ? (
                    <div className="flex flex-col gap-2 overflow-y-auto max-h-96">
                        {pages.filter(p => p.assistantProposalImage).map(page => (
                            <div key={`thumb-${page.id}`} onClick={() => setCurrentPageId(page.id)} className={`relative rounded-lg cursor-pointer border-2 ${currentPageId === page.id ? 'border-indigo-500' : 'border-transparent'}`}>
                                <img 
                                    src={page.assistantProposalImage!} 
                                    alt={page.name}
                                    className="w-full h-auto object-cover rounded-md"
                                />
                                <div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs font-semibold text-center p-1 rounded-b-md">{page.name}</div>
                            </div>
                        ))}
                    </div>
                  ) : (
                    <>
                    {pages.map((page, index) => (
                        <div key={page.id} className={`rounded-lg p-2 font-semibold mb-2 flex items-center justify-between group ${currentPageId === page.id ? 'border-2 border-indigo-500 bg-indigo-50 text-indigo-700' : 'border border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-400'}`}>
                            <span onClick={() => setCurrentPageId(page.id)} className="flex-grow text-center cursor-pointer">{page.name}</span>
                            <div className="flex items-center">
                                {index > 0 && (
                                  <div className="relative group flex items-center">
                                        <button onClick={() => handleToggleReferencePrevious(page.id)} className="p-1 rounded-full hover:bg-indigo-100">
                                            <LinkIcon className={`w-4 h-4 transition-colors ${page.shouldReferencePrevious ? 'text-indigo-600' : 'text-gray-400'}`} />
                                        </button>
                                        <div className="absolute bottom-full right-0 mb-1 w-max max-w-xs px-2 py-1 bg-gray-800 text-white text-xs rounded-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
                                            {t('referencePreviousPage')}
                                        </div>
                                    </div>
                                )}
                                <button onClick={() => handleDeletePage(page.id)} className="p-1 rounded-full hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0" disabled={pages.length <= 1}>
                                    <TrashIcon className="w-4 h-4 text-red-500" />
                                </button>
                            </div>
                        </div>
                    ))}
                    <div onClick={() => handleAddPage()} className="border border-dashed border-gray-300 bg-gray-50 rounded-lg p-2 text-center font-semibold text-gray-500 mt-2 cursor-pointer hover:border-indigo-500 hover:text-indigo-700">
                      {t('addPage')}
                    </div>
                    </>
                  )}
                </div>

                <div className="flex flex-col gap-2">
                  <h3 className="font-bold text-sm mb-2 text-gray-500 tracking-wider uppercase">{t('characters')}</h3>
                  <div className="flex flex-col gap-2">
                      {characters.length === 0 && <p className="text-xs text-gray-400 text-center p-2">{t('createCharacterPrompt')}</p>}
                      {characters.map(char => (
                          <div key={char.id} className="flex items-center justify-between gap-3 p-2 rounded-md bg-gray-100 border border-gray-200 group relative">
                              <div className="absolute -top-2 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs px-2 py-0.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">{t('dragMe')}</div>
                              <div className="flex items-center gap-3 flex-grow cursor-grab" draggable onDragStart={(e) => { e.dataTransfer.setData('characterId', char.id); setIsDraggingCharacter(true); }} onDragEnd={() => setIsDraggingCharacter(false)}>
                                  <img src={char.sheetImage} alt={char.name} className="w-10 h-10 rounded-sm object-cover" />
                                  <span className="font-semibold text-sm text-gray-700">{char.name}</span>
                              </div>
                              <button onClick={() => handleDeleteCharacter(char.id)} className="p-1 rounded-full hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity">
                                  <TrashIcon className="w-4 h-4 text-red-500" />
                              </button>
                          </div>
                      ))}
                  </div>
                  <button onClick={() => setShowCharacterModal(true)} className="w-full mt-2 flex items-center justify-center gap-2 border border-dashed border-gray-300 bg-gray-50 rounded-lg p-2 text-center font-semibold text-gray-500 cursor-pointer hover:border-indigo-500 hover:text-indigo-700">
                    <AddUserIcon className="w-5 h-5" /> {t('addCharacter')}
                  </button>
                </div>
              </aside>

              <main className="flex-1 p-4 lg:p-6 overflow-auto relative">
                {viewMode === 'result' && currentPage.generatedImage && currentPage.panelLayoutImage ? (
                  <ComparisonViewer 
                      beforeImage={currentPage.panelLayoutImage}
                      afterImage={currentPage.generatedImage}
                      isMonochromeResult={isMonochromeResult}
                      onColorize={handleColorize}
                      isColoring={isColoring}
                  />
                ) : (
                  <PanelEditor 
                      ref={panelEditorRef}
                      key={currentPage.id}
                      shapes={currentPage.shapes}
                      onShapesChange={handleShapesChange}
                      characters={characters}
                      aspectRatio={currentPage.aspectRatio}
                      viewTransform={currentPage.viewTransform}
                      onViewTransformChange={handleViewTransformChange}
                      isDraggingCharacter={isDraggingCharacter}
                      onUndo={handleUndo}
                      onRedo={handleRedo}
                      canUndo={currentPage.shapesHistoryIndex > 0}
                      canRedo={currentPage.shapesHistoryIndex < currentPage.shapesHistory.length - 1}
                      proposalImage={currentPage.assistantProposalImage}
                      proposalOpacity={currentPage.proposalOpacity}
                      isProposalVisible={currentPage.isProposalVisible}
                      onProposalSettingsChange={(updates) => handleUpdateCurrentPage(updates)}
                      onApplyLayout={handleApplyLayout}
                      isFullscreen={isFullscreen}
                      onToggleFullscreen={toggleFullscreen}
                  />
                )}
              </main>
            </div>
            <aside className="w-96 bg-white p-6 border-l border-gray-200 flex flex-col gap-6 overflow-y-auto flex-shrink-0">
              {viewMode === 'result' && currentPage.generatedImage && !error ? (
                <ResultDisplay
                  isLoading={isLoading}
                  isColoring={isColoring}
                  generatedContent={{ image: currentPage.generatedImage, text: currentPage.generatedText }}
                  error={error}
                  isMonochromeResult={isMonochromeResult}
                  onColorize={handleColorize}
                  onRegenerate={handleGenerateImage}
                  onEdit={handleEditImage}
                  onStartMasking={() => setIsMasking(true)}
                  mask={currentMask}
                  onClearMask={() => setCurrentMask(null)}
                  onReturnToEditor={() => setViewMode('editor')}
                  isAnalyzing={isAnalyzing}
                  analysisResult={analysisResult}
                  onAnalyze={handleAnalyzeResult}
                  onApplyCorrection={handleApplyCorrection}
                  onClearAnalysis={() => setAnalysisResult(null)}
                  characters={characters}
                />
              ) : (
                <div className="flex flex-col gap-6 h-full">
                  {!anyLoading && <h2 className="text-xl font-bold text-gray-800">{t('generateYourManga')}</h2>}
                  {anyLoading ? (
                    <div className="flex flex-col items-center justify-center h-full text-center">
                        {assistantModeState?.hasError ? (
                            <div className="flex flex-col items-center gap-4">
                                <svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 text-red-500" viewBox="0 0 20 20" fill="currentColor">
                                  <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
                                </svg>
                                <p className="text-red-600 font-semibold px-4">{assistantModeState.statusMessage}</p>
                                <button 
                                    onClick={() => handleStartAutoGeneration(assistantModeState.totalPages, assistantModeState.failedPageNumber)}
                                    className="bg-indigo-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-indigo-500 transition-colors text-sm"
                                >
                                    {t('retryGeneration')}
                                </button>
                            </div>
                        ) : (
                            <>
                                <svg className="animate-spin h-10 w-10 text-indigo-600 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                                  <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
                                  <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                                </svg>
                                <p className="text-gray-500 mt-4 font-semibold">
                                   {assistantModeState?.isActive ? assistantModeState.statusMessage :
                                    isAnalyzing ? t('analyzing') :
                                    isSuggestingStory ? t('storySuggesting') : 
                                    isSuggestingLayout ? t('layoutSuggesting') : 
                                    isColoring ? t('coloringPage') : t('generating')}
                                </p>
                                {assistantModeState?.isActive && !assistantModeState?.hasError && (
                                    <button
                                        onClick={handleStopAutoGeneration}
                                        className="mt-4 bg-red-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-red-500 transition-colors text-sm"
                                    >
                                        {t('stopGeneration')}
                                    </button>
                                )}
                            </>
                        )}
                    </div>
                   ) : (
                    <>
                      <GenerationControls
                        onGenerateImage={handleGenerateImage}
                        isLoading={isLoading}
                        colorMode={colorMode}
                        setColorMode={setColorMode}
                        isReadyToGenerate={isReadyToGenerate}
                        sceneDescription={currentPage.sceneDescription}
                        onSceneDescriptionChange={(desc) => handleUpdateCurrentPage({ sceneDescription: desc })}
                        onSuggestLayout={handleGenerateLayoutProposal}
                        isSuggestingLayout={isSuggestingLayout}
                        onSuggestStory={() => setShowStorySuggestionModal(true)}
                        characters={characters}
                        hasGeneratedResult={!!currentPage.generatedImage}
                        onViewResult={() => setViewMode('result')}
                        generateEmptyBubbles={generateEmptyBubbles}
                        setGenerateEmptyBubbles={setGenerateEmptyBubbles}
                        assistantModeState={assistantModeState}
                      />
                    </>
                  )}
                   {error && <div className="text-red-700 bg-red-100 p-4 rounded-lg border border-red-300 text-sm">{error}</div>}
                </div>
              )}
            </aside>
          </>
        )}
      </div>
    </div>
  );
}
```

## /README.en-US.md

# AIMangaStudio

[![中文](https://img.shields.io/badge/-中文-0078D7?style=flat-square)](./README.md) [![English](https://img.shields.io/badge/-English-4CAF50?style=flat-square)](./README.en-US.md) [![日本語](https://img.shields.io/badge/-日本語-F44336?style=flat-square)](./README.ja-JP.md)

![og image](./og.webp)

A tool that uses AI to create manga, supporting script generation, storyboard layout, and character style control.

## Project Overview
AIMangaStudio provides an end-to-end pipeline for manga creators and studios, integrating story generation, panel layout, character design, and page continuity analysis to streamline the process from script to finished pages.

## Key Features
- Natural language manga script generation (story, dialogue, narration)
- AI-driven storyboard layout (speech bubbles, camera cuts)
- Character and style configuration (multiple art styles supported)
- Multi-page export (PNG, PDF)
- Creation history and versioning

## Quick Start
### Requirements
- Node.js (recommended 18+)
- npm or yarn

### Install
```bash
npm install
# or
# yarn
```

### Development
```bash
npm run dev
```

### Build & Preview
```bash
npm run build
npm run preview
```

## Tech Stack
- Frontend: React + Vite + TypeScript
- AI: Google GenAI (via `@google/genai`)
- Deployment: Vercel / Netlify / Docker (supported)

## Target Users
- Independent creators
- Manga enthusiasts
- Content studios
- Social media creators

## Contributing
Contributions welcome — please open issues or PRs and follow code style and contribution guidelines.


## License
MIT


## /README.ja-JP.md

# AIMangaStudio

[![中文](https://img.shields.io/badge/-中文-0078D7?style=flat-square)](./README.md) [![English](https://img.shields.io/badge/-English-4CAF50?style=flat-square)](./README.en-US.md) [![日本語](https://img.shields.io/badge/-日本語-F44336?style=flat-square)](./README.ja-JP.md)

![og image](./og.webp)

AIを活用してマンガを制作するためのツールで、脚本生成、絵コンテレイアウト、キャラクタースタイル管理をサポートします。

## プロジェクト概要
AIMangaStudio は、創作者やスタジオ向けに、ストーリー生成、コマ割り、キャラクターデザイン、ページ連続性の解析を統合したエンドツーエンドの制作パイプラインを提供し、脚本から完成ページまでのワークフローを簡素化します。

## 主な機能
- 自然言語によるマンガ脚本生成(ストーリー、セリフ、ナレーション)
- AIによる絵コンテ自動配置(吹き出し、カメラワーク)
- キャラクターとスタイル設定(複数の描画スタイルに対応)
- 複数ページのエクスポート(PNG、PDF)
- 制作履歴とバージョン管理

## クイックスタート
### 要件
- Node.js(推奨 18+)
- npm または yarn

### インストール
```bash
npm install
# または
# yarn
```

### 開発
```bash
npm run dev
```

### ビルドとプレビュー
```bash
npm run build
npm run preview
```

## 技術スタック
- フロントエンド: React + Vite + TypeScript
- AI: Google GenAI(`@google/genai` を使用)
- デプロイ: Vercel / Netlify / Docker(対応)

## 対象ユーザー
- 個人クリエイター
- マンガ愛好家
- コンテンツ制作スタジオ
- ソーシャルメディアクリエイター

## コントリビュート
Issue や PR を歓迎します。コードスタイルと貢献ガイドに従ってください。

## ライセンス
MIT


## /README.md

# AIMangaStudio

[![中文](https://img.shields.io/badge/-中文-0078D7?style=flat-square)](./README.md) [![English](https://img.shields.io/badge/-English-4CAF50?style=flat-square)](./README.en-US.md) [![日本語](https://img.shields.io/badge/-日本語-F44336?style=flat-square)](./README.ja-JP.md)

![og image](./og.webp)

一个利用 AI 制作漫画的工具,支持脚本创作、分镜设计和角色风格控制。

## 项目概述
AIMangaStudio 旨在为独立创作者与工作室提供一套端到端的漫画创作流水线,集成剧情生成、分镜布局、角色设定与页间连续性分析等功能,简化从脚本到漫画页面的制作流程。

## 主要功能
1. 自然语言生成漫画脚本(剧情、对白、旁白)

    ![](AI%20剧情建议.jpg)

2. 角色与风格设定(支持多种绘画风格)
    ![](创建角色.jpg)

3. AI 分镜自动排版(对话框、镜头切换)
    ![](风格设定.jpg)

4. 多页漫画导出(PNG、PDF)

    ![](批量导出.jpg)

## 快速开始
### 环境要求
- Node.js(建议 18+)
- npm 或 yarn

### 安装
```bash
npm install
# 或
# yarn
```

### 开发
```bash
npm run dev
```

### 构建与预览
```bash
npm run build
npm run preview
```

## 技术栈
- 前端:React + Vite + TypeScript
- AI:Google GenAI(通过 `@google/genai` 包)


## 一键部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/morsoli/aimangastudio)

[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/morsoli/aimangastudio)


## 目标用户
- 独立创作者
- 漫画爱好者
- 内容工作室
- 自媒体运营者

## 贡献
欢迎提交 issue 或 pull request。请遵循项目的代码风格与贡献指南。

## 许可证
MIT


## /components/ApiKeyModal.tsx

```tsx path="/components/ApiKeyModal.tsx" 
import React, { useState } from 'react';
import { useLocalization } from '../hooks/useLocalization';

interface ApiKeyModalProps {
  isOpen: boolean;
  onClose: () => void;
  onSave: (apiKey: string) => void;
}

export function ApiKeyModal({ isOpen, onClose, onSave }: ApiKeyModalProps): React.ReactElement | null {
  const { t } = useLocalization();
  const [apiKey, setApiKey] = useState('');
  const [isSaving, setIsSaving] = useState(false);

  if (!isOpen) return null;

  const handleSave = async () => {
    if (!apiKey.trim()) return;
    
    setIsSaving(true);
    try {
      onSave(apiKey.trim());
    } finally {
      setIsSaving(false);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      handleSave();
    }
  };

  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
      <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
        <h2 className="text-xl font-bold text-gray-900 mb-4">
          {t('apiKeyRequired')}
        </h2>
        
        <div className="mb-4">
          <p className="text-sm text-gray-600 mb-2">
            {t('apiKeyDescription')}
          </p>
          <p className="text-xs text-gray-500 mb-4">
            {t('apiKeyStorageWarning')}
          </p>
          
          <input
            type="password"
            value={apiKey}
            onChange={(e) => setApiKey(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder={t('apiKeyPlaceholder')}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
            autoFocus
          />
        </div>

        <div className="flex gap-3 justify-end">
          <button
            onClick={onClose}
            className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
          >
            {t('cancel')}
          </button>
          <button
            onClick={handleSave}
            disabled={!apiKey.trim() || isSaving}
            className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            {isSaving ? t('saving') : t('save')}
          </button>
        </div>
      </div>
    </div>
  );
}
```

## /components/CharacterGenerationModal.tsx

```tsx path="/components/CharacterGenerationModal.tsx" 
import React, { useState, useCallback, useRef } from 'react';
import type { Character } from '../types';
import { UploadIcon, XIcon, RedoAltIcon, SparklesIcon, PlusIcon, CheckCircleIcon } from './icons';
import { useLocalization } from '../hooks/useLocalization';
import { generateCharacterSheet, editCharacterSheet, generateCharacterFromReference } from '../services/geminiService';

interface CharacterGenerationModalProps {
  onClose: () => void;
  onSave: (character: Omit<Character, 'id'>) => void;
  characters: Character[];
}

type CharacterDraft = {
    id: number;
    name: string;
    description: string;
    concept: string;
    creationMode: 'new' | 'fromReference';
    referenceCharacterIds: string[];
    referenceImages: string[];
    sheetColorMode: 'color' | 'monochrome';
    generatedSheet: string | null;
    isGenerating: boolean;
    isEditing: boolean;
    error: string | null;
    editPrompt: string;
};

const createNewDraft = (): CharacterDraft => ({
  id: Date.now(),
  name: '',
  description: '',
  concept: '',
  creationMode: 'new',
  referenceCharacterIds: [],
  referenceImages: [],
  sheetColorMode: 'monochrome',
  generatedSheet: null,
  isGenerating: false,
  isEditing: false,
  error: null,
  editPrompt: '',
});

export function CharacterGenerationModal({ onClose, onSave, characters }: CharacterGenerationModalProps) {
  const { t } = useLocalization();
  const [drafts, setDrafts] = useState<CharacterDraft[]>([createNewDraft()]);
  const [isBatchGenerating, setIsBatchGenerating] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [activeUploaderId, setActiveUploaderId] = useState<number | null>(null);

  const handleUpdateDraft = (id: number, updates: Partial<CharacterDraft>) => {
    setDrafts(prev => prev.map(d => d.id === id ? { ...d, ...updates } : d));
  };
  
  const handleAddDraft = () => {
    setDrafts(prev => [...prev, createNewDraft()]);
  };
  
  const handleRemoveDraft = (id: number) => {
    setDrafts(prev => prev.filter(d => d.id !== id));
  };

  const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    if (!activeUploaderId) return;
    const files = event.target.files;
    if (files) {
      const fileArray = Array.from(files);
      fileArray.forEach(file => {
        const reader = new FileReader();
        reader.onloadend = () => {
          setDrafts(prev => prev.map(d => 
              d.id === activeUploaderId 
              ? { ...d, referenceImages: [...d.referenceImages, reader.result as string] }
              : d
          ));
        };
        reader.readAsDataURL(file);
      });
    }
  }, [activeUploaderId]);
  
  const triggerUploader = (id: number) => {
    setActiveUploaderId(id);
    fileInputRef.current?.click();
  };
  
  const handleGenerate = async (id: number) => {
    const draft = drafts.find(d => d.id === id);
    if (!draft || !draft.name) {
      handleUpdateDraft(id, { error: t('characterNamePlaceholder') });
      return;
    }
    handleUpdateDraft(id, { isGenerating: true, error: null, generatedSheet: null });
    
    try {
      let result: string;
      if (draft.creationMode === 'new') {
        if (draft.referenceImages.length === 0) throw new Error(t('characterNameAndImageError'));
        result = await generateCharacterSheet(draft.referenceImages, draft.name, draft.sheetColorMode);
      } else { // fromReference
        if (draft.referenceCharacterIds.length === 0) throw new Error(t('referenceCharacterError'));
        if (!draft.concept) throw new Error(t('characterConceptError'));
        const refChars = characters.filter(c => draft.referenceCharacterIds.includes(c.id));
        if (refChars.length === 0) throw new Error('Reference characters not found.');
        const refSheetImages = refChars.map(c => c.sheetImage);
        result = await generateCharacterFromReference(refSheetImages, draft.name, draft.concept, draft.sheetColorMode);
      }
      handleUpdateDraft(id, { generatedSheet: result, isGenerating: false });
    } catch (e) {
      handleUpdateDraft(id, { error: e instanceof Error ? e.message : "An unknown error occurred.", isGenerating: false });
    }
  };

  const handleBatchGenerate = async () => {
    setIsBatchGenerating(true);
    const draftsToGenerate = drafts.filter(d => !d.generatedSheet && d.name && (d.referenceImages.length > 0 || (d.creationMode === 'fromReference' && d.referenceCharacterIds.length > 0 && d.concept)));
    await Promise.all(draftsToGenerate.map(d => handleGenerate(d.id)));
    setIsBatchGenerating(false);
  };

  const handleEdit = async (id: number) => {
    const draft = drafts.find(d => d.id === id);
    if (!draft || !draft.generatedSheet || !draft.editPrompt) {
      handleUpdateDraft(id, { error: t('editPromptError') });
      return;
    }
    handleUpdateDraft(id, { isEditing: true, error: null });

    try {
        const result = await editCharacterSheet(draft.generatedSheet, draft.name, draft.editPrompt);
        handleUpdateDraft(id, { generatedSheet: result, editPrompt: '' });
    } catch (e) {
        handleUpdateDraft(id, { error: e instanceof Error ? e.message : "An unknown error occurred during update." });
    } finally {
        handleUpdateDraft(id, { isEditing: false });
    }
  };


  const handleSave = (id: number) => {
    const draft = drafts.find(d => d.id === id);
    if (draft && draft.generatedSheet && draft.name) {
      const characterToSave: Omit<Character, 'id'> = draft.creationMode === 'new'
        ? { name: draft.name, sheetImage: draft.generatedSheet, referenceImages: draft.referenceImages, description: draft.description }
        : { name: draft.name, sheetImage: draft.generatedSheet, referenceImages: [], description: draft.concept };
      
      if (draft.creationMode === 'new' && draft.referenceImages.length === 0) return;
      
      onSave(characterToSave);
      handleRemoveDraft(id);
    }
  };
  
  const handleSaveAllAndClose = () => {
    drafts.forEach(draft => {
        if (draft.generatedSheet && draft.name) {
            const characterToSave: Omit<Character, 'id'> = draft.creationMode === 'new'
              ? { name: draft.name, sheetImage: draft.generatedSheet, referenceImages: draft.referenceImages, description: draft.description }
              : { name: draft.name, sheetImage: draft.generatedSheet, referenceImages: [], description: draft.concept };
            if (draft.creationMode === 'new' && draft.referenceImages.length === 0) return;
            onSave(characterToSave);
        }
    });
    onClose();
  };

  const hasUnsavedDrafts = drafts.length > 0;
  const readyToGenerate = drafts.some(d => d.name && (d.referenceImages.length > 0 || (d.creationMode === 'fromReference' && d.referenceCharacterIds.length > 0 && d.concept)));

  return (
    <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
      <div className="bg-white rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] flex flex-col">
        <div className="p-6 border-b border-gray-200 flex justify-between items-center">
          <div>
            <h2 className="text-xl font-bold text-gray-800">{t('createCharacter')}</h2>
            <p className="text-sm text-gray-500">{t('batchCharacterDesc')}</p>
          </div>
          <button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100"><XIcon className="w-5 h-5 text-gray-600" /></button>
        </div>

        <div className="p-6 flex-grow overflow-y-auto bg-gray-50/50">
            <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
                {drafts.map(draft => {
                    const toggleRefChar = (draftId: number, charId: string) => {
                        handleUpdateDraft(draftId, { 
                            referenceCharacterIds: draft.referenceCharacterIds.includes(charId)
                                ? draft.referenceCharacterIds.filter(id => id !== charId)
                                : [...draft.referenceCharacterIds, charId]
                        });
                    };

                    return (
                        <div key={draft.id} className="bg-white rounded-lg border border-gray-200 shadow-sm flex flex-col transition-all">
                            {/* Header */}
                            <div className="p-3 border-b border-gray-200 flex justify-between items-center bg-gray-50 rounded-t-lg">
                                <input 
                                    type="text"
                                    value={draft.name}
                                    onChange={(e) => handleUpdateDraft(draft.id, { name: e.target.value })}
                                    placeholder={t('characterNamePlaceholder')}
                                    className="w-full bg-transparent font-semibold text-gray-700 outline-none placeholder:font-normal"
                                />
                                <button onClick={() => handleRemoveDraft(draft.id)} className="p-1 rounded-full hover:bg-red-100 text-red-500">
                                    <XIcon className="w-4 h-4" />
                                </button>
                            </div>

                            {/* Body */}
                            <div className="p-4 flex-grow">
                                {draft.generatedSheet ? (
                                    <div className="flex flex-col gap-4">
                                        <div className="relative aspect-[4/3] bg-gray-100 rounded-md flex items-center justify-center">
                                           <img src={draft.generatedSheet} alt={t('generatedSheet')} className="max-h-full w-auto object-contain rounded-md" />
                                           {(draft.isGenerating || draft.isEditing) && <div className="absolute inset-0 bg-white/70 flex items-center justify-center"><svg className="animate-spin h-6 w-6 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>}
                                        </div>
                                        <div className="flex items-center gap-2">
                                            <button onClick={() => handleGenerate(draft.id)} className="flex-1 flex items-center justify-center gap-1.5 bg-gray-200 text-gray-700 font-semibold py-1.5 px-3 rounded-md hover:bg-gray-300 text-xs"><RedoAltIcon className="w-4 h-4" /> {t('regenerate')}</button>
                                            <button onClick={() => handleSave(draft.id)} className="flex-1 flex items-center justify-center gap-1.5 bg-green-600 text-white font-semibold py-1.5 px-3 rounded-md hover:bg-green-500 text-xs"><CheckCircleIcon className="w-4 h-4" /> {t('saveCharacter')}</button>
                                        </div>
                                        <div>
                                            <textarea
                                                value={draft.editPrompt}
                                                onChange={(e) => handleUpdateDraft(draft.id, { editPrompt: e.target.value })}
                                                placeholder={t('editSheetPlaceholder')}
                                                className="w-full bg-gray-50 border border-gray-300 rounded-md p-2 text-xs h-16 resize-none"
                                            />
                                            <button onClick={() => handleEdit(draft.id)} disabled={draft.isEditing || !draft.editPrompt} className="w-full mt-1.5 flex items-center justify-center gap-1.5 bg-gray-700 text-white font-semibold py-1.5 px-3 rounded-md hover:bg-gray-600 disabled:bg-gray-400 text-xs"><SparklesIcon className="w-4 h-4"/> {t('updateSheet')}</button>
                                        </div>
                                    </div>
                                ) : (
                                    <div className="flex flex-col gap-3">
                                        <div className="flex rounded-lg bg-gray-100 p-1 w-full text-xs font-semibold">
                                            <button onClick={() => handleUpdateDraft(draft.id, { creationMode: 'new' })} className={`w-1/2 p-1.5 rounded-md ${draft.creationMode === 'new' ? 'bg-white shadow' : 'text-gray-500'}`}>{t('createNewFromScratch')}</button>
                                            <button onClick={() => handleUpdateDraft(draft.id, { creationMode: 'fromReference' })} disabled={characters.length === 0} className={`w-1/2 p-1.5 rounded-md ${draft.creationMode === 'fromReference' ? 'bg-white shadow' : 'text-gray-500'} disabled:text-gray-400 disabled:cursor-not-allowed`}>{t('createFromReference')}</button>
                                        </div>
                                        
                                        {draft.creationMode === 'new' ? (
                                            <>
                                                <textarea value={draft.description} onChange={(e) => handleUpdateDraft(draft.id, { description: e.target.value })} placeholder={t('characterDescriptionPlaceholder')} className="w-full bg-gray-50 border border-gray-300 rounded-md p-2 text-xs h-16 resize-y" />
                                                <div className="grid grid-cols-3 gap-2 p-1.5 bg-gray-100 border border-gray-200 rounded-lg min-h-[5rem]">
                                                    {draft.referenceImages.map((img, index) => (
                                                        <div key={index} className="relative group aspect-square">
                                                            <img src={img} alt={`Ref ${index + 1}`} className="w-full h-full object-cover rounded" />
                                                            <button onClick={() => handleUpdateDraft(draft.id, { referenceImages: draft.referenceImages.filter((_, i) => i !== index) })} className="absolute top-0.5 right-0.5 bg-black/60 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100"><XIcon className="w-3 h-3" /></button>
                                                        </div>
                                                    ))}
                                                    {draft.referenceImages.length < 8 &&
                                                      <div onClick={() => triggerUploader(draft.id)} className="cursor-pointer border-2 border-dashed border-gray-300 rounded text-center hover:border-indigo-500 flex flex-col items-center justify-center aspect-square"><UploadIcon className="w-5 h-5 text-gray-400" /><span className="text-xs text-gray-500 mt-1">+ Add</span></div>
                                                    }
                                                </div>
                                            </>
                                        ) : (
                                            <div className="flex flex-col gap-2">
                                                 <div className="bg-gray-100 border border-gray-200 rounded-lg p-2">
                                                    <p className="text-xs font-semibold text-gray-600 mb-2">{t('selectReferenceCharacter')}</p>
                                                    <div className="grid grid-cols-3 gap-2 max-h-32 overflow-y-auto">
                                                        {characters.map(c => (
                                                            <div key={c.id} onClick={() => toggleRefChar(draft.id, c.id)} className={`relative group cursor-pointer aspect-square rounded border-2 ${draft.referenceCharacterIds.includes(c.id) ? 'border-indigo-500' : 'border-transparent'}`}>
                                                                <img src={c.sheetImage} alt={c.name} className="w-full h-full object-cover rounded" />
                                                                {draft.referenceCharacterIds.includes(c.id) && (
                                                                    <div className="absolute inset-0 bg-indigo-600/60 rounded-sm flex items-center justify-center">
                                                                        <CheckCircleIcon className="w-6 h-6 text-white" />
                                                                    </div>
                                                                )}
                                                            </div>
                                                        ))}
                                                    </div>
                                                 </div>
                                                <textarea value={draft.concept} onChange={(e) => handleUpdateDraft(draft.id, { concept: e.target.value })} placeholder={t('conceptPlaceholder')} className="w-full bg-gray-50 border border-gray-300 rounded-md p-2 text-xs h-16 resize-y" />
                                            </div>
                                        )}

                                        <div className="flex rounded-lg bg-gray-100 p-1 w-full mt-auto">
                                            <button onClick={() => handleUpdateDraft(draft.id, { sheetColorMode: 'monochrome' })} className={`w-1/2 px-3 py-1.5 text-xs font-semibold rounded-md transition-colors ${draft.sheetColorMode === 'monochrome' ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:bg-gray-200'}`}> {t('monochrome')} </button>
                                            <button onClick={() => handleUpdateDraft(draft.id, { sheetColorMode: 'color' })} className={`w-1/2 px-3 py-1.5 text-xs font-semibold rounded-md transition-colors ${draft.sheetColorMode === 'color' ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:bg-gray-200'}`}> {t('color')} </button>
                                        </div>
                                        <button onClick={() => handleGenerate(draft.id)} disabled={draft.isGenerating || !draft.name || (draft.creationMode === 'new' && draft.referenceImages.length === 0) || (draft.creationMode === 'fromReference' && (!draft.referenceCharacterIds.length || !draft.concept)) } className="w-full bg-indigo-600 text-white font-bold py-2 rounded-lg hover:bg-indigo-500 disabled:bg-gray-400">
                                            {draft.isGenerating ? t('generating') : t('generateSheet')}
                                        </button>
                                    </div>
                                )}
                                {draft.error && <p className="text-red-500 text-xs text-center mt-2">{draft.error}</p>}
                            </div>
                        </div>
                    )
                })}
                
                {/* Add New Card */}
                 <div onClick={handleAddDraft} className="bg-gray-100 rounded-lg border-2 border-dashed border-gray-300 hover:border-indigo-500 hover:bg-white transition-colors flex flex-col items-center justify-center text-gray-500 cursor-pointer min-h-[300px]">
                    <PlusIcon className="w-8 h-8" />
                    <p className="mt-2 font-semibold">{t('addCharacter')}</p>
                 </div>
            </div>
             <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/png, image/jpeg, image/webp" className="hidden" multiple />
        </div>
        
        <div className="p-4 bg-white border-t border-gray-200 flex justify-between items-center">
          <button onClick={handleBatchGenerate} disabled={isBatchGenerating || !readyToGenerate} className="bg-purple-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-purple-500 transition-colors text-sm disabled:bg-gray-400">
            {isBatchGenerating ? t('generating') : t('generateAll')}
          </button>
          <div className="flex gap-3">
            <button onClick={onClose} className={`bg-white border border-gray-300 text-gray-700 font-semibold py-2 px-5 rounded-lg hover:bg-gray-100 transition-colors text-sm ${!hasUnsavedDrafts ? 'hidden' : ''}`}>
                {t('cancel')}
            </button>
            <button onClick={handleSaveAllAndClose} className="bg-green-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-green-500 transition-colors text-sm">
                {hasUnsavedDrafts ? t('saveAndClose') : t('close')}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}
```

## /components/CharacterUploader.tsx

```tsx path="/components/CharacterUploader.tsx" 
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { UploadIcon, CheckCircleIcon } from './icons';

interface CharacterUploaderProps {
  onImageUpload: (base64: string) => void;
  hasImage: boolean;
  image: string | null;
}

export function CharacterUploader({ onImageUpload, hasImage, image }: CharacterUploaderProps): React.ReactElement {
  const [preview, setPreview] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    setPreview(image);
  }, [image]);

  const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        const base64String = reader.result as string;
        setPreview(base64String);
        onImageUpload(base64String);
      };
      reader.readAsDataURL(file);
    }
  }, [onImageUpload]);
  
  const handleClick = () => {
    fileInputRef.current?.click();
  };

  return (
    <div className="bg-white rounded-xl p-5 border border-gray-200 shadow-sm">
      <div className="flex items-center justify-between mb-4">
        <h2 className="text-md font-semibold text-gray-700">1. Upload Character Sheet</h2>
        {hasImage && <CheckCircleIcon className="w-6 h-6 text-green-500" />}
      </div>
      <div 
        onClick={handleClick}
        className="cursor-pointer border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-indigo-500 hover:bg-indigo-50 transition-colors"
      >
        <input
          type="file"
          ref={fileInputRef}
          onChange={handleFileChange}
          accept="image/png, image/jpeg, image/webp"
          className="hidden"
        />
        {preview ? (
          <div className="relative group">
            <img src={preview} alt="Character preview" className="mx-auto max-h-40 rounded-md" />
            <div className="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
                <p className="text-white font-semibold text-sm">Click to change image</p>
            </div>
          </div>
        ) : (
          <div className="flex flex-col items-center justify-center py-6">
            <UploadIcon className="w-10 h-10 text-gray-400 mb-2" />
            <p className="font-semibold text-gray-600">Click to upload</p>
            <p className="text-sm text-gray-400">PNG, JPG, WEBP</p>
          </div>
        )}
      </div>
    </div>
  );
}
```

## /components/ComparisonViewer.tsx

```tsx path="/components/ComparisonViewer.tsx" 
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from './icons';
import { useLocalization } from '../hooks/useLocalization';

interface ComparisonViewerProps {
  beforeImage: string;
  afterImage: string;
  isMonochromeResult: boolean;
  onColorize: () => void;
  isColoring: boolean;
}

export function ComparisonViewer({ beforeImage, afterImage, isMonochromeResult, onColorize, isColoring }: ComparisonViewerProps): React.ReactElement {
  const { t } = useLocalization();
  const [sliderPosition, setSliderPosition] = useState(50);
  const containerRef = useRef<HTMLDivElement>(null);
  const isDragging = useRef(false);

  const handleMove = useCallback((clientX: number) => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
    const percent = (x / rect.width) * 100;
    setSliderPosition(percent);
  }, []);
  
  const handleMouseDown = useCallback(() => { isDragging.current = true; }, []);
  const handleMouseUp = useCallback(() => { isDragging.current = false; }, []);
  
  const handleMouseMove = useCallback((e: MouseEvent) => {
    if (isDragging.current) handleMove(e.clientX);
  }, [handleMove]);

  const handleTouchMove = useCallback((e: TouchEvent) => {
    if (isDragging.current) handleMove(e.touches[0].clientX);
  }, [handleMove]);
  
  useEffect(() => {
    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('mouseup', handleMouseUp);
    window.addEventListener('touchmove', handleTouchMove);
    window.addEventListener('touchend', handleMouseUp);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      window.removeEventListener('mouseup', handleMouseUp);
      window.removeEventListener('touchmove', handleTouchMove);
      window.removeEventListener('touchend', handleMouseUp);
    };
  }, [handleMouseMove, handleMouseUp, handleTouchMove]);

  return (
    <div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm h-full flex flex-col items-center justify-center">
        <div className="w-full flex justify-between items-center mb-4 px-2">
            <h2 className="text-lg font-semibold text-gray-700">{t('compareResult')}</h2>
            {isMonochromeResult && (
                <button
                    onClick={onColorize}
                    disabled={isColoring}
                    className="bg-teal-500 text-white font-bold py-2 px-5 rounded-lg hover:bg-teal-600 transition-colors disabled:bg-gray-400"
                >
                    {isColoring ? t('colorizing') : t('colorizePage')}
                </button>
            )}
        </div>
        <div className="flex-grow w-full h-full flex items-center justify-center bg-gray-100 rounded-lg overflow-hidden p-4">
            <div 
                ref={containerRef}
                className="relative w-full max-w-[600px] max-h-full rounded-lg overflow-hidden select-none shadow-lg border border-gray-300"
            >
                <img src={beforeImage} alt="Before - Panel Layout" className="block w-full h-auto object-contain pointer-events-none" draggable={false}/>
                <div className="absolute inset-0 w-full h-full overflow-hidden pointer-events-none" style={{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`}}>
                    <img src={afterImage} alt="After - Generated Manga" className="absolute inset-0 w-full h-full object-contain" draggable={false}/>
                </div>
                <div
                    className="absolute top-0 bottom-0 w-1 bg-white/70 cursor-ew-resize backdrop-blur-sm" style={{ left: `${sliderPosition}%`, transform: 'translateX(-50%)' }}
                    onMouseDown={handleMouseDown} onTouchStart={handleMouseDown}
                >
                    <div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 bg-white rounded-full p-1.5 shadow-lg border-2 border-indigo-500">
                        <div className="flex items-center text-indigo-600">
                            <ChevronLeftIcon className="w-5 h-5" />
                            <ChevronRightIcon className="w-5 h-5" />
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
  );
}
```

## /components/GenerationControls.tsx

```tsx path="/components/GenerationControls.tsx" 
import React from 'react';
import { useLocalization } from '../hooks/useLocalization';
import { WandIcon, LightbulbIcon } from './icons';
import type { Character } from '../types';

interface GenerationControlsProps {
  onGenerateImage: () => void;
  isLoading: boolean;
  colorMode: 'color' | 'monochrome';
  setColorMode: (mode: 'color' | 'monochrome') => void;
  isReadyToGenerate: boolean;
  sceneDescription: string;
  onSceneDescriptionChange: (desc: string) => void;
  onSuggestLayout: () => void;
  isSuggestingLayout: boolean;
  onSuggestStory: () => void;
  characters: Character[];
  hasGeneratedResult: boolean;
  onViewResult: () => void;
  generateEmptyBubbles: boolean;
  setGenerateEmptyBubbles: (value: boolean) => void;
  assistantModeState: {
    isActive: boolean;
    totalPages: number;
    currentPageNumber: number;
    statusMessage: string;
  } | null;
}

export function GenerationControls({ 
    onGenerateImage,
    isLoading, 
    colorMode, 
    setColorMode, 
    isReadyToGenerate,
    sceneDescription,
    onSceneDescriptionChange,
    onSuggestLayout,
    isSuggestingLayout,
    onSuggestStory,
    characters,
    hasGeneratedResult,
    onViewResult,
    generateEmptyBubbles,
    setGenerateEmptyBubbles,
    assistantModeState
}: GenerationControlsProps): React.ReactElement {
  const { t } = useLocalization();
  
  const canSuggestLayout = !!sceneDescription;

  return (
    <div className="bg-white rounded-xl p-5 border border-gray-200 shadow-sm flex-grow flex flex-col">
       <div className="flex justify-between items-center mb-4">
         <h2 className="text-md font-semibold text-gray-700">{t('generateYourManga')}</h2>
         {hasGeneratedResult && (
            <button onClick={onViewResult} className="text-sm font-semibold text-indigo-600 hover:underline">
                {t('viewResult')}
            </button>
         )}
       </div>
        
        <div className="flex flex-col gap-2 flex-grow">
            <div className="flex justify-between items-center">
                <h3 className="text-sm font-semibold text-gray-600">{t('sceneScript')}</h3>
                 <button
                    onClick={onSuggestStory}
                    className="flex items-center gap-2 bg-yellow-400 text-yellow-900 font-bold py-1.5 px-3 rounded-lg hover:bg-yellow-500 transition-colors text-xs"
                >
                    <LightbulbIcon className="w-4 h-4" />
                    {t('getAiSuggestions')}
                </button>
            </div>
            <textarea
                value={sceneDescription}
                onChange={(e) => onSceneDescriptionChange(e.target.value)}
                placeholder={t('sceneScriptPlaceholder')}
                className="w-full flex-grow bg-gray-50 border border-gray-300 rounded-md p-3 text-xs focus:ring-2 focus:ring-indigo-500 outline-none transition resize-y"
                aria-label="Editable scene script"
            />
        </div>

        <div className="flex flex-col gap-4 mt-4">
            <button
                onClick={onSuggestLayout}
                disabled={!canSuggestLayout || isSuggestingLayout}
                className="w-full bg-purple-600 text-white font-bold py-2.5 px-4 rounded-lg hover:bg-purple-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center justify-center text-sm gap-2"
            >
                <WandIcon className="w-5 h-5" />
                {isSuggestingLayout ? t('layoutSuggesting') : t('suggestLayout')}
            </button>
            <div className="flex rounded-lg bg-gray-100 p-1 w-full">
            <button
                onClick={() => setColorMode('monochrome')}
                className={`w-1/2 px-3 py-2 text-sm font-semibold rounded-md transition-colors ${colorMode === 'monochrome' ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:bg-gray-200'}`}
            >
                {t('monochrome')}
            </button>
            <button
                onClick={() => setColorMode('color')}
                className={`w-1/2 px-3 py-2 text-sm font-semibold rounded-md transition-colors ${colorMode === 'color' ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:bg-gray-200'}`}
            >
                {t('color')}
            </button>
            </div>
            <div className="flex items-center justify-center gap-2 text-sm">
                <input
                    id="empty-bubbles"
                    type="checkbox"
                    checked={generateEmptyBubbles}
                    onChange={(e) => setGenerateEmptyBubbles(e.target.checked)}
                    className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
                />
                <label htmlFor="empty-bubbles" className="font-medium text-gray-700">{t('generateEmptyBubbles')}</label>
            </div>
            
            <button
                onClick={onGenerateImage}
                disabled={isLoading || !isReadyToGenerate}
                className="w-full bg-green-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-green-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center justify-center text-sm"
            >
                {isLoading ? `${t('generating')}...` : t('generateFinalPage')}
            </button>
            
            {!isReadyToGenerate && <p className="text-xs text-center text-gray-500 col-span-2">{t('writeScriptPrompt')}</p>}
        </div>
    </div>
  );
}
```

## /components/Header.tsx

```tsx path="/components/Header.tsx" 
import React, { useState } from 'react';
import { MenuIcon, XIcon, BookOpenIcon, GlobeIcon, VideoIcon, ArrowLeftIcon, KeyIcon } from './icons';
import { useLocalization } from '../hooks/useLocalization';
import { Language } from '../i18n/locales';

interface HeaderProps {
    isSidebarOpen: boolean;
    onToggleSidebar: () => void;
    language: Language;
    setLanguage: (language: Language) => void;
    // Optional callback to open API Key modal/settings
    onOpenApiKeyModal?: () => void;
    hasApiKey?: boolean;
    onShowMangaViewer: () => void;
    onShowWorldview: () => void;
    currentView: 'manga-editor' | 'video-producer';
    onSetView: (view: 'manga-editor' | 'video-producer') => void;
}

export function Header({ isSidebarOpen, onToggleSidebar, language, setLanguage, onOpenApiKeyModal, hasApiKey, onShowMangaViewer, onShowWorldview, currentView, onSetView }: HeaderProps): React.ReactElement {
  const { t } = useLocalization();
  const [isLangOpen, setIsLangOpen] = useState(false);

  const languages: { key: Language, name: string }[] = [
      { key: 'zh', name: t('chinese') },
      { key: 'en', name: t('english') },
      { key: 'ja', name: t('japanese') },
  ];

  return (
    <header className="bg-white border-b border-gray-200 w-full z-20 relative">
      <div className="container mx-auto px-4 lg:px-6 py-3 flex justify-between items-center">
        <div className="flex items-center gap-3">
            {currentView === 'video-producer' ? (
                <button onClick={() => onSetView('manga-editor')} className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-indigo-600 p-2 rounded-md hover:bg-gray-100" title={t('backToEditor')}>
                    <ArrowLeftIcon className="w-5 h-5" />
                    <span className="hidden md:inline">{t('backToEditor')}</span>
                </button>
            ) : (
                <button onClick={onToggleSidebar} className="p-2 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500">
                    {isSidebarOpen ? <XIcon className="w-6 h-6 text-gray-700" /> : <MenuIcon className="w-6 h-6 text-gray-700" />}
                </button>
            )}

            <div className="w-10 h-10 rounded-lg flex items-center justify-center">
                {currentView === 'video-producer' 
                    ? <VideoIcon className="w-6 h-6 text-white" /> 
                    : <svg version="1.0" xmlns="http://www.w3.org/2000/svg"
                        width="1350.000000pt" height="1362.000000pt" viewBox="0 0 1350.000000 1362.000000"
                        preserveAspectRatio="xMidYMid meet">
                        <g transform="translate(0.000000,1362.000000) scale(0.100000,-0.100000)"
                        fill="#000000" stroke="none">
                        <path d="M8768 13489 c-10 -5 -18 -16 -18 -23 0 -20 -129 -194 -186 -251 -65
                        -65 -123 -85 -248 -85 -86 0 -183 11 -233 26 -20 5 -23 3 -23 -20 0 -20 9 -30
                        39 -44 81 -39 263 -195 302 -259 20 -33 21 -43 15 -211 -3 -97 -7 -190 -8
                        -207 -2 -21 2 -30 14 -33 11 -2 24 13 43 50 23 45 126 189 182 253 40 45 64
                        52 203 59 74 4 167 3 206 -2 122 -14 126 4 17 75 -106 69 -233 180 -261 228
                        -22 38 -22 47 -18 210 2 94 8 187 11 208 8 40 -2 47 -37 26z m-82 -371 c4 -18
                        7 -39 5 -46 -1 -8 15 -34 36 -60 43 -52 83 -123 83 -147 0 -17 -21 -20 -36 -5
                        -7 7 -18 2 -34 -15 -13 -14 -32 -25 -42 -25 -10 0 -37 -12 -60 -27 -65 -44
                        -82 -53 -102 -53 -14 0 -17 5 -13 23 4 15 -1 29 -17 44 -13 13 -27 40 -31 60
                        -3 21 -20 55 -37 76 -18 23 -31 52 -32 70 -1 30 1 32 34 33 38 0 120 38 133
                        60 4 8 16 14 26 14 9 0 26 7 37 15 29 22 42 18 50 -17z"/>
                        <path d="M6844 13111 c-51 -31 -74 -72 -74 -131 0 -31 7 -64 18 -82 22 -38 80
                        -68 133 -68 113 0 187 130 126 221 -50 75 -137 100 -203 60z"/>
                        <path d="M7678 12721 c-54 -34 -73 -70 -73 -136 0 -106 64 -161 177 -153 52 4
                        63 9 96 41 35 36 37 40 37 105 0 75 -18 109 -76 144 -38 23 -124 23 -161 -1z
                        m90 -139 c2 -7 -3 -12 -12 -12 -9 0 -16 7 -16 16 0 17 22 14 28 -4z"/>
                        <path d="M7050 12604 c-14 -7 -39 -14 -57 -14 -42 0 -63 -9 -63 -27 0 -7 11
                        -49 24 -91 26 -87 45 -158 57 -217 4 -22 12 -58 17 -80 6 -22 17 -85 26 -140
                        8 -55 21 -134 27 -175 20 -126 29 -263 35 -545 6 -270 -9 -537 -37 -635 -17
                        -57 -17 -165 0 -165 26 0 41 50 71 240 12 72 25 153 30 180 45 260 63 585 49
                        894 -6 113 -17 240 -25 281 -7 42 -14 90 -14 107 0 17 -5 44 -10 59 -6 15 -15
                        52 -20 83 -30 189 -40 242 -45 251 -8 13 -35 11 -65 -6z"/>
                        <path d="M8291 12143 c-92 -120 -134 -170 -212 -255 -130 -141 -131 -141 -375
                        -135 -149 3 -209 9 -239 20 -87 35 -124 6 -52 -40 23 -16 44 -30 47 -33 3 -3
                        25 -21 50 -39 114 -87 227 -214 264 -298 17 -39 18 -52 7 -165 -21 -228 -30
                        -294 -46 -332 -12 -28 -13 -41 -5 -51 18 -21 34 -9 81 61 24 35 47 64 51 64 4
                        0 14 13 22 28 8 15 61 75 118 133 94 97 108 107 157 119 36 9 84 11 145 6 50
                        -3 129 -9 176 -12 47 -3 103 -10 125 -16 22 -5 50 -12 63 -15 15 -4 22 -1 22
                        9 0 21 -18 39 -88 92 -83 63 -241 220 -278 275 l-29 45 2 175 c1 124 7 198 19
                        251 29 130 20 172 -25 113z m-136 -384 c4 -10 10 -19 14 -19 11 0 25 -58 22
                        -92 -1 -16 3 -28 8 -28 6 0 11 -9 11 -20 0 -29 38 -106 69 -139 27 -29 46 -61
                        56 -97 4 -17 -1 -23 -34 -32 -21 -7 -62 -12 -91 -12 -31 0 -70 -8 -94 -19 -23
                        -11 -55 -25 -73 -31 -18 -7 -35 -20 -38 -29 -7 -21 -92 -74 -109 -68 -9 4 -15
                        35 -20 94 -10 131 -38 202 -113 288 -65 75 -66 74 77 95 120 17 213 58 249
                        109 23 33 56 33 66 0z"/>
                        <path d="M8986 12098 c-8 -13 -20 -47 -26 -76 -7 -28 -16 -54 -21 -58 -5 -3
                        -9 -15 -9 -27 -1 -51 -59 -88 -165 -106 -38 -7 -82 -15 -97 -17 -16 -3 -28
                        -11 -28 -19 0 -14 68 -45 100 -45 11 0 49 -14 85 -31 46 -21 70 -40 81 -61 21
                        -40 44 -131 44 -173 0 -83 32 -87 55 -7 39 131 48 154 69 178 24 29 100 59
                        175 69 51 7 90 31 74 45 -4 4 -35 16 -68 25 -102 31 -164 65 -182 99 -9 17
                        -24 71 -34 120 -20 102 -30 119 -53 84z m26 -295 c15 -15 28 -32 28 -39 0 -23
                        -39 -42 -68 -35 -21 6 -28 14 -30 40 -2 23 2 37 15 47 24 18 23 18 55 -13z"/>
                        <path d="M6252 11881 c-56 -77 -194 -209 -227 -217 -45 -11 -257 3 -304 21
                        -48 17 -92 17 -98 -1 -2 -6 18 -26 44 -43 61 -40 178 -154 211 -206 23 -36 25
                        -48 23 -135 -2 -100 -3 -109 -28 -207 -15 -60 -13 -93 5 -93 5 0 28 24 49 53
                        59 77 177 192 216 211 31 14 52 15 164 7 70 -5 148 -17 172 -25 43 -16 81 -14
                        81 4 0 4 -22 24 -49 44 -83 61 -215 207 -229 253 -7 23 -10 47 -8 54 3 8 10
                        61 15 119 6 58 17 124 25 148 16 44 13 72 -8 72 -6 0 -30 -27 -54 -59z m-94
                        -290 c17 -10 42 -58 42 -80 0 -9 9 -35 20 -59 26 -56 18 -72 -34 -72 -28 0
                        -54 -9 -84 -29 -52 -34 -97 -32 -88 4 4 16 -3 31 -24 53 -18 19 -27 38 -24 47
                        4 8 1 21 -5 29 -22 26 18 78 75 98 53 19 101 23 122 9z"/>
                        <path d="M9856 11858 c-75 -72 -75 -144 0 -215 41 -39 49 -43 97 -43 53 0 79
                        12 128 58 20 20 24 32 24 85 0 50 -5 68 -23 92 -38 48 -72 65 -129 65 -48 0
                        -56 -4 -97 -42z"/>
                        <path d="M9573 11602 c-17 -15 -64 -49 -104 -77 -86 -59 -315 -231 -414 -310
                        -116 -93 -467 -418 -488 -453 -12 -19 -1 -42 20 -42 10 1 45 26 78 57 105 98
                        202 171 417 315 47 32 88 58 91 58 3 0 35 20 71 45 37 25 70 45 75 45 4 0 27
                        14 49 30 23 17 45 30 48 30 3 0 25 13 48 28 22 15 48 31 56 35 84 42 176 99
                        178 111 2 8 -8 32 -22 53 -14 21 -26 43 -26 49 0 6 -10 20 -23 32 l-22 21 -32
                        -27z"/>
                        <path d="M6457 11012 c-26 -26 -47 -50 -47 -54 0 -4 20 -26 45 -49 121 -111
                        229 -335 247 -509 2 -17 9 -25 23 -25 17 0 20 8 23 54 6 95 -50 325 -103 432
                        -40 78 -118 198 -130 198 -5 0 -31 -21 -58 -47z"/>
                        <path d="M8360 10674 c-6 -14 -10 -32 -10 -39 0 -24 -40 -123 -59 -146 -23
                        -28 -77 -46 -167 -54 -41 -4 -77 -12 -81 -18 -8 -13 37 -47 64 -47 29 0 119
                        -36 154 -61 36 -25 69 -99 69 -153 0 -83 52 -104 63 -26 3 23 18 67 34 98 23
                        47 36 61 76 80 26 13 77 26 113 29 35 3 67 10 70 14 11 18 -17 47 -48 52 -66
                        9 -167 53 -179 77 -27 55 -39 93 -39 130 0 71 -41 115 -60 64z m38 -292 c3 -9
                        -2 -13 -14 -10 -9 1 -19 9 -22 16 -3 9 2 13 14 10 9 -1 19 -9 22 -16z"/>
                        <path d="M9290 10653 c-91 -63 -101 -179 -23 -255 32 -31 38 -33 106 -33 56 0
                        78 4 98 20 86 65 77 207 -15 265 -43 26 -131 27 -166 3z"/>
                        <path d="M7905 10600 c-22 -11 -45 -19 -52 -20 -7 0 -27 -6 -45 -14 -18 -7
                        -58 -21 -88 -30 -30 -9 -71 -23 -90 -30 -80 -31 -127 -50 -157 -62 -17 -8 -34
                        -14 -37 -14 -10 0 -268 -129 -301 -150 -16 -11 -39 -24 -50 -30 -43 -22 -273
                        -177 -290 -196 -5 -6 -30 -26 -55 -44 -115 -87 -234 -198 -288 -270 -15 -19
                        -35 -44 -44 -54 -13 -14 -15 -23 -7 -32 19 -23 0 -73 -34 -90 -32 -16 -117
                        -101 -117 -117 0 -5 -4 -5 -10 -2 -5 3 -10 24 -10 45 0 21 5 42 10 45 6 3 10
                        20 10 36 0 17 4 38 9 47 18 36 21 88 7 107 -14 20 -15 20 -22 0 -4 -11 -22
                        -40 -38 -65 -31 -44 -66 -113 -144 -280 -86 -185 -102 -220 -102 -229 0 -7
                        -24 -67 -76 -189 -8 -18 -14 -39 -14 -47 0 -8 -6 -29 -14 -47 -33 -78 -70
                        -235 -95 -398 -25 -162 -27 -245 -6 -253 19 -8 28 8 36 63 10 67 29 161 39
                        195 22 73 51 157 59 173 4 9 23 49 41 87 66 143 144 290 166 316 8 8 14 19 14
                        23 0 5 14 25 30 46 17 21 30 41 30 45 0 4 29 46 66 93 145 193 419 469 577
                        581 26 19 47 38 47 43 0 4 6 8 14 8 8 0 16 4 18 9 5 14 238 175 359 249 81 49
                        92 58 85 79 -9 30 66 93 110 93 10 0 26 8 36 17 16 14 23 15 39 5 11 -7 24
                        -10 29 -7 6 4 10 1 10 -4 0 -6 -5 -11 -10 -11 -6 0 -16 -12 -23 -27 -11 -24
                        -130 -153 -141 -153 -3 0 -6 10 -8 22 -4 31 -24 33 -32 4 -3 -13 -23 -42 -44
                        -64 -34 -35 -142 -185 -142 -197 0 -3 -19 -35 -41 -72 -23 -38 -59 -104 -81
                        -148 -21 -44 -43 -87 -47 -95 -32 -56 -114 -291 -145 -415 -26 -102 -46 -272
                        -56 -465 -6 -102 -11 -196 -12 -210 -4 -34 41 -43 49 -10 10 39 99 205 150
                        280 26 39 53 81 60 94 7 12 24 35 38 50 14 15 33 40 43 54 35 57 194 262 287
                        372 53 63 101 125 107 136 6 12 22 27 35 33 23 11 24 13 10 29 -14 16 -12 22
                        25 77 74 111 92 120 101 51 5 -35 1 -57 -19 -103 -31 -71 -28 -67 -53 -53 -17
                        9 -21 8 -21 -5 0 -9 -9 -33 -19 -53 -29 -58 -43 -102 -70 -222 -19 -89 -50
                        -387 -71 -695 -5 -66 -13 -149 -20 -185 -38 -221 -53 -296 -60 -305 -4 -5 -10
                        -21 -13 -35 -16 -74 -103 -264 -147 -320 -15 -19 -29 -42 -32 -50 -20 -52
                        -202 -233 -348 -346 -41 -32 -81 -64 -88 -71 -7 -7 -22 -13 -33 -13 -12 0 -19
                        -7 -19 -20 0 -32 -30 -70 -54 -70 -13 0 -33 -11 -46 -25 -18 -19 -33 -25 -54
                        -23 -48 5 -53 28 -15 72 39 43 77 66 113 66 17 0 31 10 45 34 12 19 28 36 36
                        40 12 4 40 42 82 111 26 44 76 151 90 194 48 151 81 433 54 465 -18 22 -41 8
                        -41 -24 0 -28 -8 -70 -37 -205 -33 -149 -171 -386 -290 -496 -53 -49 -168
                        -139 -178 -139 -3 0 -21 -11 -38 -24 -44 -33 -161 -93 -196 -101 -21 -4 -44 2
                        -87 23 -33 17 -84 42 -114 57 -30 14 -75 37 -100 49 -25 13 -98 49 -162 80
                        -81 39 -125 67 -142 89 -31 41 -98 166 -126 237 -12 30 -28 69 -35 85 -14 31
                        -34 103 -55 190 -12 49 -23 132 -36 283 -7 72 6 221 27 332 6 30 15 77 20 104
                        4 27 13 54 19 60 5 5 10 19 10 31 0 11 7 29 15 39 8 11 15 26 15 33 0 7 16 45
                        35 85 38 77 44 87 88 156 15 24 27 45 27 47 0 28 258 338 332 400 56 46 219
                        197 280 258 32 31 62 57 68 57 5 0 10 13 10 28 0 60 95 109 120 63 13 -26 13
                        -67 -2 -90 -6 -10 -8 -26 -5 -35 7 -18 27 -22 27 -6 0 6 16 33 35 60 19 27 35
                        52 35 54 0 3 20 34 45 70 25 37 45 68 45 70 0 8 76 115 114 160 32 39 37 49
                        25 57 -20 13 -35 11 -53 -6 -8 -8 -25 -20 -38 -25 -13 -6 -48 -25 -80 -43 -31
                        -18 -76 -43 -100 -56 -23 -13 -62 -36 -86 -52 -24 -16 -45 -29 -47 -29 -6 0
                        -168 -114 -240 -168 -260 -198 -531 -491 -678 -734 -26 -43 -47 -81 -47 -85 0
                        -3 -6 -14 -14 -22 -12 -14 -54 -113 -108 -256 -31 -84 -56 -217 -69 -370 -18
                        -206 1 -401 57 -595 34 -115 89 -248 130 -312 35 -53 35 -68 4 -68 -60 0 -151
                        -66 -187 -137 -70 -134 -35 -291 77 -355 22 -13 40 -30 40 -39 0 -8 -17 -42
                        -38 -75 -21 -32 -47 -75 -58 -94 -10 -19 -38 -63 -61 -98 -24 -35 -43 -65 -43
                        -67 0 -7 -149 -226 -181 -266 -16 -20 -29 -40 -29 -43 0 -3 -26 -42 -57 -85
                        -32 -44 -94 -130 -138 -191 -73 -103 -420 -575 -448 -610 -7 -8 -35 -46 -62
                        -85 -27 -38 -75 -103 -107 -142 -32 -40 -58 -75 -58 -78 0 -3 -33 -50 -73
                        -103 -72 -95 -287 -409 -287 -419 0 -3 -13 -22 -30 -43 -17 -21 -30 -41 -30
                        -44 0 -4 -12 -25 -27 -49 -15 -23 -32 -50 -38 -59 -5 -10 -42 -71 -82 -135
                        -80 -132 -124 -210 -184 -328 -22 -44 -44 -87 -49 -95 -5 -8 -18 -35 -28 -60
                        -11 -25 -37 -80 -57 -124 -33 -71 -49 -113 -75 -186 -5 -14 -18 -50 -30 -80
                        -12 -30 -24 -66 -27 -80 -3 -14 -9 -30 -14 -35 -4 -6 -13 -31 -19 -55 -5 -25
                        -19 -76 -31 -115 -11 -38 -24 -90 -29 -115 -5 -25 -13 -63 -18 -85 -29 -118
                        -55 -360 -50 -462 l3 -55 -70 -37 c-39 -20 -100 -49 -135 -64 -36 -15 -73 -32
                        -82 -37 -26 -14 -141 -51 -283 -89 -90 -25 -193 -41 -300 -47 -116 -7 -132
                        -14 -119 -49 8 -20 91 -20 318 0 271 23 488 66 638 126 25 10 77 30 116 44 38
                        14 112 47 164 74 126 65 137 71 179 104 20 15 40 27 44 27 10 0 141 92 220
                        155 39 31 86 68 105 83 130 102 440 419 606 618 49 60 238 312 280 374 17 25
                        52 75 77 113 26 37 47 69 47 71 0 3 21 35 47 73 53 77 193 309 193 320 0 3 6
                        14 14 22 8 9 30 45 49 81 18 36 44 82 56 102 32 53 95 174 206 398 53 107 106
                        213 118 235 22 43 26 51 49 105 8 19 24 55 36 80 53 118 75 167 86 195 16 37
                        72 160 92 198 8 16 14 35 14 42 0 7 11 36 25 64 14 28 44 99 66 158 22 60 45
                        112 50 118 5 5 9 18 9 28 0 10 6 31 14 45 8 15 19 41 24 57 6 17 24 68 42 115
                        58 158 100 290 100 315 0 31 21 32 80 4 64 -31 106 -34 174 -11 74 25 146 86
                        177 149 30 61 34 184 6 226 -28 43 -23 67 16 68 50 1 159 23 184 37 12 6 29
                        12 37 12 8 0 32 7 53 16 21 9 53 23 71 30 17 8 49 22 70 31 45 21 205 114 212
                        123 3 3 26 21 52 38 57 39 196 178 259 258 50 65 109 152 109 161 0 3 13 27
                        28 52 15 25 38 71 51 101 12 30 26 64 30 75 5 11 14 34 21 50 46 113 95 407
                        111 669 5 82 11 157 14 167 4 10 10 73 15 141 10 132 32 299 50 382 22 99 67
                        258 86 304 8 18 14 37 14 43 0 21 127 266 173 337 26 39 61 82 77 96 33 28 45
                        72 19 70 -20 -2 -167 -98 -289 -190 -226 -171 -491 -453 -641 -684 -61 -94
                        -119 -198 -119 -214 0 -15 -28 -36 -48 -36 -18 0 -22 32 -8 58 8 15 31 68 52
                        117 20 50 51 122 69 160 18 39 45 97 59 130 15 33 34 74 43 90 9 17 34 64 56
                        105 34 65 57 105 115 195 6 11 15 27 19 35 4 8 36 56 72 105 37 50 79 108 94
                        131 73 108 296 340 400 416 37 28 66 55 64 62 -6 19 -50 17 -92 -4z m-2464
                        -3405 c44 -24 51 -28 119 -59 19 -9 42 -20 50 -26 29 -17 386 -190 394 -190 4
                        0 36 -19 71 -41 48 -32 65 -50 75 -79 14 -43 6 -65 -43 -117 -41 -43 -79 -43
                        -165 1 -119 60 -164 82 -387 192 -121 59 -234 119 -252 132 -72 53 -61 134 27
                        195 33 23 59 21 111 -8z m816 -221 c2 -6 -13 -24 -32 -39 -27 -20 -40 -24 -48
                        -16 -8 8 -3 19 18 41 29 30 55 36 62 14z m-820 -230 c47 -32 237 -124 255
                        -124 18 0 38 -20 38 -38 0 -8 -8 -33 -19 -56 -36 -80 -51 -117 -51 -127 0 -6
                        -6 -25 -14 -42 -14 -35 -40 -100 -62 -159 -34 -92 -47 -125 -61 -158 -39 -91
                        -45 -105 -89 -222 -9 -24 -20 -50 -24 -59 -4 -8 -34 -76 -65 -149 -61 -143
                        -64 -148 -89 -197 -9 -17 -16 -36 -16 -41 0 -5 -25 -63 -56 -128 -104 -221
                        -185 -389 -194 -404 -5 -8 -34 -64 -65 -125 -31 -60 -77 -149 -103 -197 -26
                        -49 -65 -121 -86 -160 -21 -40 -50 -91 -64 -113 -14 -22 -36 -58 -48 -80 -28
                        -52 -49 -88 -104 -175 -25 -39 -52 -82 -60 -95 -45 -71 -219 -324 -241 -350
                        -14 -16 -35 -46 -47 -65 -11 -19 -29 -44 -39 -55 -10 -11 -43 -51 -73 -90
                        -259 -334 -611 -661 -922 -859 -51 -32 -109 -70 -129 -83 -19 -12 -43 -23 -53
                        -23 -16 0 -18 5 -12 38 4 20 11 82 16 137 9 100 32 243 49 300 18 61 39 131
                        56 190 10 33 24 75 31 93 8 18 14 39 14 47 0 7 8 28 17 47 9 18 22 45 30 60 7
                        14 13 35 13 45 0 11 7 28 15 39 8 10 15 25 15 32 0 27 204 440 229 465 6 6 11
                        17 11 23 0 7 12 32 28 56 44 69 51 82 62 103 6 11 18 32 28 47 9 14 30 48 46
                        75 57 95 321 489 378 565 32 42 58 79 58 82 0 3 112 156 248 338 212 284 349
                        471 442 603 18 26 90 125 212 295 197 274 418 638 418 689 0 39 25 40 77 5z
                        m-2519 -4429 c3 -26 -17 -55 -39 -55 -16 0 -17 56 -1 73 21 23 36 17 40 -18z"/>
                        <path d="M3548 3629 c-114 -27 -198 -135 -198 -256 0 -122 70 -210 193 -245
                        59 -17 65 -17 117 -1 107 33 165 96 190 206 8 38 7 60 -5 109 -29 110 -78 162
                        -177 186 -53 14 -68 14 -120 1z m137 -152 c90 -93 21 -244 -102 -221 -40 8
                        -100 62 -109 100 -12 47 42 134 91 147 37 10 98 -3 120 -26z"/>
                        <path d="M8850 9915 c-8 -2 -62 -8 -120 -15 -89 -9 -210 -29 -285 -46 -178
                        -41 -383 -107 -397 -128 -4 -6 -8 -19 -8 -30 0 -21 17 -20 110 9 93 28 220 54
                        330 67 242 29 723 6 915 -44 22 -5 54 -11 70 -11 l30 -2 8 84 c4 47 4 88 0 92
                        -20 17 -581 38 -653 24z"/>
                        <path d="M9810 9896 c-47 -17 -100 -87 -100 -132 0 -91 64 -158 150 -158 123
                        0 191 129 122 229 -43 62 -108 85 -172 61z"/>
                        </g>
                        </svg>
                }
            </div>
            <h1 className="text-xl font-bold text-gray-800 tracking-tight">
                {currentView === 'video-producer' ? t('aiVideoProducer') : t('AIMangaStudio')}
            </h1>
        </div>
        <div className="flex items-center gap-4">
            {currentView === 'manga-editor' && (
                <>
                    <button onClick={() => onSetView('video-producer')} className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-indigo-600 p-2 rounded-md hover:bg-gray-100" title={t('aiVideoProducer')}>
                        <VideoIcon className="h-5 w-5" />
                    </button>
                     <button onClick={onShowMangaViewer} className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-indigo-600 p-2 rounded-md hover:bg-gray-100" title={t('viewCollection')}>
                        <BookOpenIcon className="h-5 w-5" />
                    </button>
                    <button onClick={onShowWorldview} className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-indigo-600 p-2 rounded-md hover:bg-gray-100" title={t('worldviewSettings')}>
                        <GlobeIcon className="h-5 w-5" />
                    </button>
                </>
            )}

            {/* {<div className="relative">
                 <button
                    onClick={() => setIsLangOpen(prev => !prev)}
                    className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-indigo-600 p-2 rounded-md hover:bg-gray-100"
                >
                    <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M7 2a1 1 0 011.707-.707l3.586 3.586a1 1 0 010 1.414l-3.586 3.586A1 1 0 017 9V5a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h2a1 1 0 001-1v-4a1 1 0 011-1h2.586l3.707 3.707a1 1 0 01-1.414 1.414L10 14.414V18a1 1 0 01-1 1H7a1 1 0 01-1-1v-2a1 1 0 00-1-1H4a1 1 0 00-1 1v2a3 3 0 003 3h4a1 1 0 00.707-1.707L10 18.586V14.5a1 1 0 011-1h1.293l4.293 4.293a1 1 0 001.414-1.414L14.414 13H16a3 3 0 003-3V7a3 3 0 00-3-3h-4a1 1 0 00-1 1v2.586L9.707 2.293A1 1 0 019 2H7z" clipRule="evenodd" /></svg>
                    <span>{languages.find(l => l.key === language)?.name}</span>
                </button>
                {isLangOpen && (
                    <div className="absolute top-full right-0 mt-2 w-36 bg-white border border-gray-200 rounded-md shadow-lg z-30">
                        {languages.map(({ key, name }) => (
                            <div key={key} onClick={() => { setLanguage(key); setIsLangOpen(false); }} className={`px-4 py-2 text-sm hover:bg-indigo-50 cursor-pointer ${language === key ? 'font-bold text-indigo-600' : 'text-gray-700'}`}>
                                {name}
                            </div>
                        ))}
                    </div>
                )}
            </div> } */}

            {/* API Key settings button (visible when callback provided) */}
            {typeof onOpenApiKeyModal === 'function' && (
                <button
                    onClick={onOpenApiKeyModal}
                    className="relative flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-indigo-600 p-2 rounded-md hover:bg-gray-100"
                    title={t('apiKeySettings')}
                    aria-label={t('apiKeySettings')}
                >
                    <KeyIcon className="h-5 w-5" />
                    {hasApiKey && (
                        <span className="absolute -top-1 -right-1 inline-flex items-center justify-center h-3 w-3 rounded-full bg-green-500 ring-2 ring-white" aria-hidden="true"></span>
                    )}
                </button>
            )}

            <button className="bg-indigo-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-indigo-500 transition-colors text-sm">
              {t('export')}
            </button>
        </div>
      </div>
    </header>
  );
}

```

## /components/MangaViewerModal.tsx

```tsx path="/components/MangaViewerModal.tsx" 
import React, { useState, useMemo } from 'react';
import type { Page } from '../types';
import { useLocalization } from '../hooks/useLocalization';
import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from './icons';

interface MangaViewerModalProps {
    pages: Page[];
    onClose: () => void;
}

export function MangaViewerModal({ pages, onClose }: MangaViewerModalProps) {
    const { t } = useLocalization();
    const [pageIndex, setPageIndex] = useState(0);

    const generatedPages = useMemo(() => pages.filter(p => p.generatedImage), [pages]);

    const handleNext = () => {
        setPageIndex(prev => Math.min(prev + 1, generatedPages.length - 1));
    };

    const handlePrev = () => {
        setPageIndex(prev => Math.max(prev - 1, 0));
    };
    
    const handleDownloadAll = () => {
        generatedPages.forEach((page, index) => {
            if (!page.generatedImage) return;
            const link = document.createElement('a');
            link.href = page.generatedImage;
            link.download = `manga-page-${index + 1}.png`;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        });
    };
    
    if (generatedPages.length === 0) {
        return (
            <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
                <div className="bg-white rounded-xl shadow-2xl p-8 text-center" onClick={e => e.stopPropagation()}>
                    <p className="text-gray-600">No generated pages to display yet.</p>
                </div>
            </div>
        )
    }

    const currentPage = generatedPages[pageIndex];

    return (
        <div className="fixed inset-0 bg-black/70 backdrop-blur-md flex flex-col items-center justify-center z-50 p-4" onClick={onClose}>
            <div className="w-full max-w-5xl p-4 flex justify-between items-center text-white">
                 <h2 className="text-xl font-bold">{t('mangaViewerTitle')}</h2>
                 <div className="flex items-center gap-4">
                     <button onClick={handleDownloadAll} className="flex items-center gap-2 bg-indigo-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-indigo-500 transition-colors text-sm">
                        <DownloadIcon className="w-5 h-5" />
                        {t('downloadAll')}
                    </button>
                    <button onClick={onClose} className="p-2 rounded-full bg-white/10 hover:bg-white/20">
                        <XIcon className="w-6 h-6"/>
                    </button>
                 </div>
            </div>

            <div className="flex-grow w-full flex items-center justify-center relative" onClick={e => e.stopPropagation()}>
                <button 
                    onClick={handlePrev} 
                    disabled={pageIndex === 0}
                    className="absolute left-4 p-3 rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-30 disabled:cursor-not-allowed transition-opacity"
                >
                    <ChevronLeftIcon className="w-8 h-8 text-white" />
                </button>

                <div className="flex flex-col items-center justify-center gap-4 h-full">
                    {currentPage.generatedImage && (
                         <img 
                            src={currentPage.generatedImage} 
                            alt={`Manga page ${pageIndex + 1}`} 
                            className="max-h-full max-w-full object-contain rounded-md shadow-2xl"
                            style={{ maxHeight: 'calc(100vh - 150px)'}}
                        />
                    )}
                    <p className="text-white/80 font-semibold text-lg bg-black/30 px-4 py-1 rounded-full">
                        {t('pageIndicator').replace('{currentPage}', String(pageIndex + 1)).replace('{totalPages}', String(generatedPages.length))}
                    </p>
                </div>

                 <button 
                    onClick={handleNext} 
                    disabled={pageIndex >= generatedPages.length - 1}
                    className="absolute right-4 p-3 rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-30 disabled:cursor-not-allowed transition-opacity"
                >
                    <ChevronRightIcon className="w-8 h-8 text-white" />
                </button>
            </div>
        </div>
    );
}

```

## /components/MaskingModal.tsx

```tsx path="/components/MaskingModal.tsx" 
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useLocalization } from '../hooks/useLocalization';
import { BrushIcon, TrashIcon, XIcon, UndoIcon } from './icons';

interface MaskingModalProps {
  baseImage: string;
  onClose: () => void;
  onSave: (maskDataUrl: string) => void;
}

export function MaskingModal({ baseImage, onClose, onSave }: MaskingModalProps) {
  const { t } = useLocalization();
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const imageRef = useRef<HTMLImageElement>(null);
  const [isDrawing, setIsDrawing] = useState(false);
  const [brushSize, setBrushSize] = useState(40);
  const [history, setHistory] = useState<ImageData[]>([]);

  const getCanvasContext = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return null;
    return canvas.getContext('2d');
  }, []);
  
  // Set custom brush cursor
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const cursorCanvas = document.createElement('canvas');
    const size = brushSize;
    const center = size / 2 + 1;
    cursorCanvas.width = size + 2;
    cursorCanvas.height = size + 2;
    const ctx = cursorCanvas.getContext('2d');
    if (ctx) {
        ctx.beginPath();
        ctx.arc(center, center, size / 2, 0, 2 * Math.PI);
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)';
        ctx.lineWidth = 1;
        ctx.stroke();
    }
    const dataUrl = cursorCanvas.toDataURL('image/png');
    canvas.style.cursor = `url(${dataUrl}) ${center} ${center}, crosshair`;
  }, [brushSize]);

  const saveToHistory = useCallback(() => {
    const ctx = getCanvasContext();
    const canvas = canvasRef.current;
    if (!ctx || !canvas) return;
    setHistory(prev => [...prev, ctx.getImageData(0, 0, canvas.width, canvas.height)]);
  }, [getCanvasContext]);

  const handleImageLoad = useCallback(() => {
    const img = imageRef.current;
    const canvas = canvasRef.current;
    const ctx = getCanvasContext();
    if (!img || !canvas || !ctx) return;
    
    // Set canvas resolution to image's natural resolution
    canvas.width = img.naturalWidth;
    canvas.height = img.naturalHeight;

    // Match canvas display size to the image's rendered size to avoid distortion
    const { clientWidth, clientHeight } = img;
    canvas.style.width = `${clientWidth}px`;
    canvas.style.height = `${clientHeight}px`;
    
    // Draw initial black background for the mask
    ctx.fillStyle = 'black';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    saveToHistory();
  }, [getCanvasContext, saveToHistory]);
  
  const handleUndo = () => {
    const ctx = getCanvasContext();
    if (!ctx || history.length <= 1) return;
    
    const newHistory = history.slice(0, -1);
    const lastState = newHistory[newHistory.length - 1];
    if (lastState) {
        ctx.putImageData(lastState, 0, 0);
    }
    setHistory(newHistory);
  };


  const getCoords = (e: React.MouseEvent | React.TouchEvent) => {
    const canvas = canvasRef.current;
    if (!canvas) return { x: 0, y: 0 };
    const rect = canvas.getBoundingClientRect();
    const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
    const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
    return {
      x: (clientX - rect.left) / rect.width * canvas.width,
      y: (clientY - rect.top) / rect.height * canvas.height,
    };
  };

  const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
    const ctx = getCanvasContext();
    if (!ctx) return;
    setIsDrawing(true);
    const { x, y } = getCoords(e);
    ctx.beginPath();
    ctx.moveTo(x, y);
  };

  const draw = (e: React.MouseEvent | React.TouchEvent) => {
    if (!isDrawing) return;
    const ctx = getCanvasContext();
    if (!ctx) return;
    const { x, y } = getCoords(e);
    ctx.lineTo(x, y);
    ctx.strokeStyle = 'white'; // Draw white on a black background
    ctx.lineWidth = brushSize;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.stroke();
  };

  const stopDrawing = () => {
    const ctx = getCanvasContext();
    if (!ctx) return;
    ctx.closePath();
    setIsDrawing(false);
    saveToHistory();
  };

  const handleClear = () => {
    const ctx = getCanvasContext();
    const canvas = canvasRef.current;
    if (!ctx || !canvas) return;
    ctx.fillStyle = 'black';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    saveToHistory();
  };

  const handleSave = () => {
    const canvas = canvasRef.current;
    if (canvas) {
      onSave(canvas.toDataURL('image/png'));
    }
  };

  return (
    <div className="fixed inset-0 bg-black/70 backdrop-blur-md flex flex-col items-center justify-center z-50 p-4" onClick={onClose}>
        <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[95vh] flex flex-col" onClick={e => e.stopPropagation()}>
            <div className="p-4 border-b border-gray-200 flex justify-between items-center">
                <div>
                    <h3 className="text-lg font-bold text-gray-800">{t('maskingModalTitle')}</h3>
                    <p className="text-sm text-gray-500">{t('maskingModalDescription')}</p>
                </div>
                <button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100"><XIcon className="w-5 h-5 text-gray-600" /></button>
            </div>
            <div className="p-4 flex-grow flex items-center justify-center bg-gray-100 relative min-h-0">
                <div className="absolute top-2 left-2 z-10 bg-white shadow-lg rounded-full border border-gray-200 p-2 flex items-center gap-4">
                    <div className="flex items-center gap-2">
                        <BrushIcon className="w-5 h-5 text-gray-600" />
                        <input type="range" min="5" max="150" value={brushSize} onChange={e => setBrushSize(Number(e.target.value))} className="w-32" />
                        <span className="text-sm font-semibold text-gray-700 w-8 text-center">{brushSize}</span>
                    </div>
                    <div className="h-6 w-px bg-gray-200"></div>
                    <button onClick={handleUndo} className="p-2 hover:bg-gray-200 rounded-full" title={t('undo')} disabled={history.length <= 1}><UndoIcon className="w-5 h-5 text-gray-700" /></button>
                    <button onClick={handleClear} className="p-2 hover:bg-red-100 rounded-full" title={t('clearCanvas')}><TrashIcon className="w-5 h-5 text-red-500" /></button>
                </div>

                <div className="relative flex items-center justify-center">
                    <img 
                        ref={imageRef} 
                        src={baseImage} 
                        onLoad={handleImageLoad}
                        alt="Base for masking" 
                        className="max-w-full max-h-full object-contain pointer-events-none select-none"
                        style={{ maxHeight: 'calc(95vh - 150px)' }}
                    />
                    <canvas
                        ref={canvasRef}
                        className="absolute opacity-50"
                        onMouseDown={startDrawing}
                        onMouseMove={draw}
                        onMouseUp={stopDrawing}
                        onMouseLeave={stopDrawing}
                        onTouchStart={startDrawing}
                        onTouchMove={draw}
                        onTouchEnd={stopDrawing}
                    />
                </div>
            </div>
            <div className="p-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
                <button onClick={onClose} className="bg-white border border-gray-300 text-gray-700 font-semibold py-2 px-5 rounded-lg hover:bg-gray-100 transition-colors text-sm">{t('cancel')}</button>
                <button onClick={handleSave} className="bg-indigo-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-indigo-500 transition-colors text-sm">{t('saveMask')}</button>
            </div>
        </div>
    </div>
  );
}
```

## /components/PanelEditor.tsx

```tsx path="/components/PanelEditor.tsx" 
import React, { useState, useRef, useCallback, useImperativeHandle, useEffect, forwardRef, useMemo } from 'react';
import type { CanvasShape, BubbleShape, PanelShape, Character, ImageShape, TextShape, ViewTransform, Pose, SkeletonData, DrawingShape, SkeletonPose, ArrowShape } from '../types';
import { PolygonIcon, TextToolIcon, BubbleToolIcon, TrashIcon, SelectIcon, CircleIcon, SquareIcon, XIcon, BrushIcon, ExpandIcon, ShrinkIcon, HandIcon, PlusIcon, EditPoseIcon, MinusIcon, UploadIcon, RedoIcon, UndoIcon, EyeIcon, EyeOffIcon, ArrowIcon } from './icons';
import { useLocalization } from '../hooks/useLocalization';
import type { LocaleKeys } from '../i18n/locales';
import { PoseEditorModal } from './PoseEditorModal';

interface PanelEditorProps {
  shapes: CanvasShape[];
  onShapesChange: (shapes: CanvasShape[], recordHistory?: boolean) => void;
  characters: Character[];
  aspectRatio: string;
  viewTransform: ViewTransform;
  onViewTransformChange: (vt: ViewTransform) => void;
  isDraggingCharacter: boolean;
  onUndo: () => void;
  onRedo: () => void;
  canUndo: boolean;
  canRedo: boolean;
  proposalImage: string | null;
  proposalOpacity: number;
  isProposalVisible: boolean;
  onProposalSettingsChange: (updates: { proposalOpacity?: number; isProposalVisible?: boolean }) => void;
  onApplyLayout: () => void;
  isFullscreen: boolean;
  onToggleFullscreen: () => void;
}

const ASPECT_RATIO_CONFIG: { [key: string]: { w: number, h: number } } = {
    'A4': { w: 595, h: 842 },
    '竖版': { w: 600, h: 800 },
    '正方形': { w: 800, h: 800 },
    '横版': { w: 1280, h: 720 }
};

type Tool = 'select' | 'panel' | 'text' | 'bubble' | 'draw' | 'pan' | 'arrow';
type BubbleType = 'rounded' | 'oval' | 'rect';
type Action = 
  | { type: 'none' }
  | { type: 'creating'; shape: CanvasShape }
  | { type: 'panning'; startPos: {x: number, y: number}, startVT: ViewTransform }
  // FIX: Renamed 'panningShape' action to 'drawing' for clarity.
  | { type: 'drawing'; shapeId: string, currentStroke: {x:number, y:number}[] }
  | { type: 'dragging'; shapeId: string; startOffset: {x: number, y: number} }
  | { type: 'resizing'; shapeId: string; handle: string; originalShape: BubbleShape | ImageShape | TextShape }
  | { type: 'draggingTail'; shapeId: string }
  | { type: 'editingPanelVertex'; shapeId: string, vertexIndex: number }
  | { type: 'draggingArrowHandle'; shapeId: string; handleIndex: 0 | 1 };

const getBubblePath = (shape: BubbleShape): string => {
    const { x, y, width, height, tail, bubbleType } = shape;
    let bodyPath: string;
    if (bubbleType === 'oval') {
        const rx = width / 2;
        const ry = height / 2;
        const cx = x + rx;
        const cy = y + ry;
        bodyPath = `M ${cx - rx},${cy} a ${rx},${ry} 0 1,0 ${width},0 a ${rx},${ry} 0 1,0 ${-width},0`;
    } else {
        const cornerRadius = bubbleType === 'rounded' ? Math.min(20, width / 2, height / 2) : 0;
        bodyPath = `M ${x + cornerRadius},${y} L ${x + width - cornerRadius},${y} Q ${x + width},${y} ${x + width},${y + cornerRadius} L ${x + width},${y + height - cornerRadius} Q ${x + width},${y + height} ${x + width - cornerRadius},${y + height} L ${x + cornerRadius},${y + height} Q ${x},${y + height} ${x},${y + height - cornerRadius} L ${x},${y + cornerRadius} Q ${x},${y} ${x + cornerRadius},${y} Z`;
    }
    if (!tail) return bodyPath;

    const centerX = x + width / 2;
    const centerY = y + height / 2;
    const dx = tail.x - centerX;
    const dy = tail.y - centerY;
    
    if (dx === 0 && dy === 0) return bodyPath;

    const angle = Math.atan2(dy, dx);
    const cosAngle = Math.cos(angle);
    const sinAngle = Math.sin(angle);
    
    let intersectX, intersectY;
    if (bubbleType === 'oval') {
        const rx = width / 2;
        const ry = height / 2;
        const tanAngle = Math.tan(angle);
        intersectX = centerX + rx * ry / Math.sqrt(ry*ry + rx*rx * tanAngle*tanAngle) * (Math.abs(angle) < Math.PI / 2 ? 1 : -1);
        intersectY = centerY + rx * ry * tanAngle / Math.sqrt(ry*ry + rx*rx * tanAngle*tanAngle) * (Math.abs(angle) < Math.PI / 2 ? 1 : -1);
    } else {
        const tX = Math.abs(cosAngle) > 1e-6 ? (width / 2) / Math.abs(cosAngle) : Infinity;
        const tY = Math.abs(sinAngle) > 1e-6 ? (height / 2) / Math.abs(sinAngle) : Infinity;
        const t = Math.min(tX, tY);
        intersectX = centerX + t * cosAngle;
        intersectY = centerY + t * sinAngle;
    }

    const tailWidth = Math.min(width, height) * 0.15;
    const p1x = intersectX - Math.sin(angle) * tailWidth * 0.5;
    const p1y = intersectY + Math.cos(angle) * tailWidth * 0.5;
    const p2x = intersectX + Math.sin(angle) * tailWidth * 0.5;
    const p2y = intersectY - Math.cos(angle) * tailWidth * 0.5;
    return `${bodyPath} M ${p1x},${p1y} L ${tail.x},${tail.y} L ${p2x},${p2y} Z`;
};

const getDrawingPath = (drawing: CanvasShape): string => {
    if (drawing.type !== 'drawing') return '';
    return drawing.points.map(stroke => 
        stroke.map((p, i) => (i === 0 ? 'M' : 'L') + `${p.x} ${p.y}`).join(' ')
    ).join(' ');
}

const getShapeBBox = (shape: CanvasShape) => {
    if (shape.type === 'panel' || shape.type === 'arrow') {
        const points = shape.points.flat();
        if (points.length === 0) return { x: shape.x, y: shape.y, width: 0, height: 0 };
        const xs = points.map(p => p.x);
        const ys = points.map(p => p.y);
        const minX = Math.min(...xs);
        const minY = Math.min(...ys);
        return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
    }
    if (shape.type === 'drawing') {
        if (shape.points.length === 0 || shape.points[0].length === 0) {
            return { x: shape.x, y: shape.y, width: 0, height: 0 };
        }
        const allPoints = shape.points.flat();
        const xs = allPoints.map(p => p.x);
        const ys = allPoints.map(p => p.y);
        const minX = Math.min(...xs);
        const minY = Math.min(...ys);
        return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
    }
    return { x: shape.x, y: shape.y, width: shape.width || 0, height: shape.height || 0 };
}

const getPolygonCentroid = (points: { x: number; y: number }[]) => {
    const getBBoxCenter = () => {
        if (points.length === 0) return { x: 0, y: 0 };
        const xs = points.map(p => p.x);
        const ys = points.map(p => p.y);
        const minX = Math.min(...xs);
        const minY = Math.min(...ys);
        return { x: minX + (Math.max(...xs) - minX) / 2, y: minY + (Math.max(...ys) - minY) / 2 };
    };

    if (points.length < 3) {
        return getBBoxCenter();
    }

    let area = 0;
    let cx = 0;
    let cy = 0;

    for (let i = 0; i < points.length; i++) {
        const p1 = points[i];
        const p2 = points[(i + 1) % points.length];
        const crossProduct = (p1.x * p2.y - p2.x * p1.y);
        area += crossProduct;
        cx += (p1.x + p2.x) * crossProduct;
        cy += (p1.y + p2.y) * crossProduct;
    }

    area /= 2;
    
    // Avoid division by zero for collinear points or very small areas
    if (Math.abs(area) < 1e-6) {
        return getBBoxCenter();
    }

    cx /= (6 * area);
    cy /= (6 * area);

    return { x: cx, y: cy };
};


const createInitialSkeleton = (x: number, y: number, width: number, height: number): SkeletonData => {
    const centerX = x + width / 2;
    const topY = y + height * 0.15; // old head position, let's use as head center
    const hipY = y + height * 0.5;
    const armY = y + height * 0.3; // neck/shoulder line
    const legY = y + height * 0.9;
    const shoulderWidth = width * 0.2;
    const hipWidth = width * 0.15;

    // Face points relative to head center (topY)
    const eyeY = topY - height * 0.03;
    const eyeDistX = width * 0.07;
    const noseY = topY;
    const mouthY = topY + height * 0.05;

    return {
        head: { x: centerX, y: topY },
        neck: { x: centerX, y: armY },
        leftShoulder: { x: centerX - shoulderWidth, y: armY },
        rightShoulder: { x: centerX + shoulderWidth, y: armY },
        leftElbow: { x: centerX - shoulderWidth * 1.5, y: hipY },
        rightElbow: { x: centerX + shoulderWidth * 1.5, y: hipY },
        leftHand: { x: centerX - shoulderWidth * 1.2, y: legY - height * 0.1 },
        rightHand: { x: centerX + shoulderWidth * 1.2, y: legY - height * 0.1 },
        hips: { x: centerX, y: hipY },
        leftHip: { x: centerX - hipWidth, y: hipY },
        rightHip: { x: centerX + hipWidth, y: hipY },
        leftKnee: { x: centerX - hipWidth, y: hipY + height * 0.2 },
        rightKnee: { x: centerX + hipWidth, y: hipY + height * 0.2 },
        leftFoot: { x: centerX - hipWidth, y: legY },
        rightFoot: { x: centerX + hipWidth, y: legY },
        // New Face Points
        leftEye: { x: centerX - eyeDistX, y: eyeY },
        rightEye: { x: centerX + eyeDistX, y: eyeY },
        nose: { x: centerX, y: noseY },
        mouth: { x: centerX, y: mouthY },
    };
};
const skeletonConnections = [
    ['head', 'neck'], ['neck', 'leftShoulder'], ['neck', 'rightShoulder'],
    ['leftShoulder', 'leftElbow'], ['leftElbow', 'leftHand'],
    ['rightShoulder', 'rightElbow'], ['rightElbow', 'rightHand'],
    ['neck', 'hips'],
    ['hips', 'leftHip'], ['hips', 'rightHip'],
    ['leftHip', 'leftKnee'], ['leftKnee', 'leftFoot'],
    ['rightHip', 'rightKnee'], ['rightKnee', 'rightFoot'],
    // Face connections
    ['leftEye', 'rightEye'],
    ['nose', 'mouth'],
];


export const PanelEditor = forwardRef<
    { getLayoutAsImage: (includeCharacters: boolean, characters: Character[]) => Promise<string> },
    PanelEditorProps
>(({ shapes, onShapesChange, characters, aspectRatio, viewTransform, onViewTransformChange, isDraggingCharacter, onUndo, onRedo, canUndo, canRedo, proposalImage, proposalOpacity, isProposalVisible, onProposalSettingsChange, onApplyLayout, isFullscreen, onToggleFullscreen }, ref) => {
  const { t } = useLocalization();
  const [activeTool, setActiveTool] = useState<Tool>('select');
  const [activeBubbleType, setActiveBubbleType] = useState<BubbleType>('rounded');
  const [action, setAction] = useState<Action>({ type: 'none' });
  const [selectedShapeId, setSelectedShapeId] = useState<string | null>(null);
  const [editingShapeId, setEditingShapeId] = useState<string | null>(null);
  const [posingCharacter, setPosingCharacter] = useState<ImageShape | null>(null);
  const [tooltip, setTooltip] = useState<{ text: string; x: number; y: number } | null>(null);
  const [brushColor, setBrushColor] = useState('#000000');
  const [brushSize, setBrushSize] = useState(5);
  const [cursorPreview, setCursorPreview] = useState<{ x: number; y: number } | null>(null);
  const [drawingGuideRect, setDrawingGuideRect] = useState<{x: number, y: number, width: number, height: number} | null>(null);
  
  const isSpacePressed = useRef(false);

  const svgRef = useRef<SVGSVGElement>(null);
  const textEditRef = useRef<HTMLTextAreaElement>(null);
  const imageUploadRef = useRef<HTMLInputElement>(null);

  const canvasConfig = useMemo(() => ASPECT_RATIO_CONFIG[aspectRatio] || ASPECT_RATIO_CONFIG['A4'], [aspectRatio]);

    const fitAndCenterCanvas = useCallback(() => {
        const svg = svgRef.current;
        if (!svg) return;
        const { width: viewWidth, height: viewHeight } = svg.getBoundingClientRect();
        if (viewWidth === 0 || viewHeight === 0) return;

        const { w: pageWidth, h: pageHeight } = canvasConfig;
        const scaleX = viewWidth / pageWidth;
        const scaleY = viewHeight / pageHeight;
        const scale = Math.min(scaleX, scaleY) * 0.9; // Fit with 10% margin
        const x = (viewWidth - (pageWidth * scale)) / 2;
        const y = (viewHeight - (pageHeight * scale)) / 2;
        onViewTransformChange({ scale, x, y });
    }, [canvasConfig, onViewTransformChange]);

  useEffect(() => {
    fitAndCenterCanvas();
  }, [fitAndCenterCanvas]);

  useEffect(() => {
    if (editingShapeId && textEditRef.current) {
        textEditRef.current.focus();
        textEditRef.current.select();
    }
  }, [editingShapeId]);
  
    const deleteShape = (id: string) => onShapesChange(shapes.filter(s => s.id !== id));

  useEffect(() => {
      const handleKeyDown = (e: KeyboardEvent) => {
          if (e.metaKey || e.ctrlKey) {
                if (e.key === 'z') {
                    e.preventDefault();
                    if (e.shiftKey) {
                        canRedo && onRedo();
                    } else {
                        canUndo && onUndo();
                    }
                }
                if (e.key === 'y') {
                    e.preventDefault();
                    canRedo && onRedo();
                }
          }
          if (e.key === ' ' && !isSpacePressed.current && !editingShapeId && !posingCharacter) {
              isSpacePressed.current = true;
              e.preventDefault();
          }
          if (e.key === 'Escape') {
            setEditingShapeId(null);
            setPosingCharacter(null);
          }
          if ((e.key === 'Delete' || e.key === 'Backspace') && selectedShapeId && !editingShapeId && !posingCharacter) {
              deleteShape(selectedShapeId);
              setSelectedShapeId(null);
          }
      };
      const handleKeyUp = (e: KeyboardEvent) => {
          if (e.key === ' ') isSpacePressed.current = false;
      };
      
      window.addEventListener('keydown', handleKeyDown);
      window.addEventListener('keyup', handleKeyUp);
      return () => {
          window.removeEventListener('keydown', handleKeyDown);
          window.removeEventListener('keyup', handleKeyUp);
      };
  }, [selectedShapeId, editingShapeId, posingCharacter, onUndo, onRedo, canUndo, canRedo]);

  const getMousePos = useCallback((e: React.MouseEvent | MouseEvent | TouchEvent) => {
    const svg = svgRef.current;
    if (!svg) return { x: 0, y: 0 };
    const CTM = svg.getScreenCTM();
    if (!CTM) return { x: 0, y: 0 };
    const pt = svg.createSVGPoint();
    const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
    const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
    pt.x = clientX;
    pt.y = clientY;
    const transformedPt = pt.matrixTransform(CTM.inverse());
    
    return {
      x: (transformedPt.x - viewTransform.x) / viewTransform.scale,
      y: (transformedPt.y - viewTransform.y) / viewTransform.scale
    };
  }, [viewTransform]);

  const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
    if (e.button !== 0) return;
     // Pan if clicking on gray area outside canvas
    if ((e.target as SVGSVGElement).isSameNode(svgRef.current)) {
        setAction({ type: 'panning', startPos: { x: e.clientX, y: e.clientY }, startVT: viewTransform });
        return;
    }

    const pos = getMousePos(e);
    if (isSpacePressed.current || activeTool === 'pan') {
        setAction({ type: 'panning', startPos: { x: e.clientX, y: e.clientY }, startVT: viewTransform });
        return;
    }
        
    setSelectedShapeId(null);
    
    const id = Date.now().toString();
    let newShape: CanvasShape | null = null;
    
    switch(activeTool) {
        case 'panel':
            newShape = { id, type: 'panel', points: [], x: pos.x, y: pos.y, width: 0, height: 0 };
            setDrawingGuideRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
            setAction({type: 'creating', shape: newShape});
            break;
        case 'text':
            newShape = { id, type: 'text', text: "Enter Text", x: pos.x, y: pos.y - 20, fontSize: 30, width: 200, height: 40 };
            onShapesChange([...shapes, newShape]);
            setActiveTool('select');
            setSelectedShapeId(id);
            setTimeout(() => setEditingShapeId(id), 0);
            return;
        case 'bubble':
            newShape = { id, type: 'bubble', bubbleType: activeBubbleType, text: 'Double-click to edit', x: pos.x, y: pos.y, width: 0, height: 0 };
            setAction({type: 'creating', shape: newShape});
            break;
        case 'draw': {
            const newStroke = [pos];
            newShape = { id, type: 'drawing', points: [newStroke], x: pos.x, y: pos.y, strokeColor: brushColor, strokeWidth: brushSize };
            setAction({type: 'drawing', shapeId: id, currentStroke: newStroke});
            onShapesChange([...shapes, newShape], false);
            return;
        }
        case 'arrow': {
            const startPoint = pos;
            newShape = { id, type: 'arrow', points: [startPoint, startPoint], x: pos.x, y: pos.y, strokeColor: '#FF0000', strokeWidth: brushSize };
            setAction({type: 'creating', shape: newShape});
            break;
        }
    }
    
    if (newShape) onShapesChange([...shapes, newShape], false);
  };

  const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
    const pos = getMousePos(e);
    if (activeTool === 'draw' || activeTool === 'arrow') {
        setCursorPreview(pos);
    } else {
        setCursorPreview(null);
    }
    if (action.type === 'none') return;
    
    let newShapes: CanvasShape[] | undefined;
    switch (action.type) {
        case 'panning': {
            const dx = e.clientX - action.startPos.x;
            const dy = e.clientY - action.startPos.y;
            onViewTransformChange({ scale: action.startVT.scale, x: action.startVT.x + dx, y: action.startVT.y + dy });
            return;
        }
        case 'creating': {
            const startX = action.shape.x;
            const startY = action.shape.y;
            const newX = Math.min(pos.x, startX);
            const newY = Math.min(pos.y, startY);
            const newWidth = Math.abs(pos.x - startX);
            const newHeight = Math.abs(pos.y - startY);

            if (action.shape.type === 'panel') {
                setDrawingGuideRect({ x: newX, y: newY, width: newWidth, height: newHeight });
            }
            if (action.shape.type === 'arrow') {
                newShapes = shapes.map(s => {
                    if (s.id !== action.shape.id) return s;
                    const newPoints: [{x:number, y:number}, {x:number, y:number}] = [(s as ArrowShape).points[0], pos];
                    return {...s, points: newPoints};
                });
                break;
            }

            newShapes = shapes.map(s => {
                if (s.id !== action.shape.id) return s;
                if(s.type === 'panel') {
                    return {...s, x: newX, y: newY, width: newWidth, height: newHeight, points: [ {x: newX, y: newY}, {x: newX + newWidth, y: newY}, {x: newX + newWidth, y: newY + newHeight}, {x: newX, y: newY + newHeight} ]}
                }
                return { ...s, x: newX, y: newY, width: newWidth, height: newHeight } as CanvasShape
            });
            break;
        }
        case 'drawing': {
            const newCurrentStroke = [...action.currentStroke, pos];
            newShapes = shapes.map(s => {
                if (s.id === action.shapeId && s.type === 'drawing') {
                    const pointsCopy: { x: number; y: number }[][] = JSON.parse(JSON.stringify(s.points));
                    pointsCopy[pointsCopy.length - 1] = newCurrentStroke;
                    return { ...s, points: pointsCopy };
                }
                return s;
            });
            setAction({ ...action, currentStroke: newCurrentStroke });
            break;
        }
        case 'dragging': {
            newShapes = shapes.map(s => {
                if (s.id !== action.shapeId) return s;
                
                const newX = pos.x - action.startOffset.x;
                const newY = pos.y - action.startOffset.y;
                const dx = newX - s.x;
                const dy = newY - s.y;

                switch (s.type) {
                    case 'panel': {
                        const newPoints = s.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
                        const xs = newPoints.map(p => p.x);
                        const ys = newPoints.map(p => p.y);
                        const minX = Math.min(...xs);
                        const minY = Math.min(...ys);
                        return { ...s, points: newPoints, x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
                    }
                    case 'arrow': {
                        const newPoints = s.points.map(p => ({ x: p.x + dx, y: p.y + dy })) as [{ x: number, y: number }, { x: number, y: number }];
                        return { ...s, points: newPoints, x: newX, y: newY };
                    }
                    case 'drawing': {
                        const newStrokes = s.points.map(stroke => stroke.map(p => ({ x: p.x + dx, y: p.y + dy })));
                        return { ...s, points: newStrokes, x: newX, y: newY };
                    }
                    default: {
                        const updatedShape = { ...s, x: newX, y: newY };
                        if (updatedShape.type === 'bubble' && updatedShape.tail) {
                            updatedShape.tail = { x: updatedShape.tail.x + dx, y: updatedShape.tail.y + dy };
                        }
                        if (updatedShape.type === 'image' && updatedShape.pose?.type === 'skeleton') {
                            const pose = updatedShape.pose;
                            const newSkeletonData: SkeletonData = {};
                            for (const key in pose.data) {
                                const point = pose.data[key as keyof SkeletonData];
                                if (point) {
                                    newSkeletonData[key as keyof SkeletonData] = {
                                        x: point.x + dx,
                                        y: point.y + dy,
                                    };
                                }
                            }
                            updatedShape.pose = { ...pose, data: newSkeletonData };
                        }
                        return updatedShape;
                    }
                }
            });
            break;
        }
        case 'draggingTail': {
            newShapes = shapes.map(s => s.id === action.shapeId ? {...s, tail: pos} as CanvasShape : s);
            break;
        }
        case 'editingPanelVertex': {
            newShapes = shapes.map(s => {
                if (s.id !== action.shapeId || s.type !== 'panel') return s;
                const newPoints = [...s.points];
                newPoints[action.vertexIndex] = pos;
                return {...s, points: newPoints};
            });
            break;
        }
        case 'draggingArrowHandle': {
            newShapes = shapes.map(s => {
                if (s.id === action.shapeId && s.type === 'arrow') {
                    const newPoints = [...s.points] as [{x:number, y:number}, {x:number, y:number}];
                    newPoints[action.handleIndex] = pos;
                    return { ...s, points: newPoints };
                }
                return s;
            });
            break;
        }
        case 'resizing': {
            const { shapeId, handle, originalShape } = action;
            let { x, y, width, height } = { ...originalShape };

            if (handle.includes('right')) {
                width = Math.max(20, pos.x - x);
            }
            if (handle.includes('bottom')) {
                height = Math.max(20, pos.y - y);
            }
            if (handle.includes('left')) {
                const newWidth = Math.max(20, (originalShape.x + originalShape.width) - pos.x);
                x = (originalShape.x + originalShape.width) - newWidth;
                width = newWidth;
            }
            if (handle.includes('top')) {
                const newHeight = Math.max(20, (originalShape.y + originalShape.height) - pos.y);
                y = (originalShape.y + originalShape.height) - newHeight;
                height = newHeight;
            }
            
            newShapes = shapes.map(s => {
                if (s.id !== shapeId) return s;
                let updatedShape = { ...s, x, y, width, height } as CanvasShape;

                if (updatedShape.type === 'image' && originalShape.type === 'image' && originalShape.width > 0 && originalShape.height > 0) {
                    if (updatedShape.pose?.type === 'skeleton' && originalShape.pose?.type === 'skeleton') {
                        const originalPose = originalShape.pose;
                        const originalSkeletonData = originalPose.data;
                        const scaleX = width / originalShape.width;
                        const scaleY = height / originalShape.height;
                        const newSkeletonData: SkeletonData = {};

                        for (const key in originalSkeletonData) {
                            const originalPoint = originalSkeletonData[key as keyof SkeletonData];
                            if (!originalPoint) continue;
                            const relativeX = originalPoint.x - originalShape.x;
                            const relativeY = originalPoint.y - originalShape.y;
                            newSkeletonData[key as keyof SkeletonData] = {
                                x: x + (relativeX * scaleX),
                                y: y + (relativeY * scaleY),
                            };
                        }
                        updatedShape.pose = { ...updatedShape.pose, data: newSkeletonData };
                    }
                }
                return updatedShape;
            });
            break;
        }
    }
    if (newShapes) onShapesChange(newShapes, false);
  };
  
  const handleMouseUp = (e: React.MouseEvent) => {
    setDrawingGuideRect(null);
    if (action.type === 'creating' && (action.shape.type === 'panel' || action.shape.type === 'bubble' || action.shape.type === 'arrow')) {
      setActiveTool('select');
      setSelectedShapeId(action.shape.id);
    }
    if (action.type === 'creating' || action.type === 'dragging' || action.type === 'resizing' || action.type === 'draggingTail' || action.type === 'editingPanelVertex' || action.type === 'drawing' || action.type === 'draggingArrowHandle') {
        const shape = shapes.find(s => s.id === (action as any).shapeId || s.id === (action as any).shape?.id);
        if (shape && (shape.type === 'bubble' || shape.type === 'panel') && (shape.width < 10 || shape.height < 10)) {
            onShapesChange(shapes.filter(s => s.id !== shape.id), true);
        } else {
             onShapesChange(shapes, true);
        }
    }
    setAction({type: 'none'});
  };
  
  const handleShapeInteraction = (e: React.MouseEvent, shape: CanvasShape, type: 'shape' | 'resize' | 'tail' | 'panelVertex' | 'arrowHandle', handle?: string | number) => {
    if (activeTool !== 'select' && activeTool !== 'pan') return;
    if (e.button !== 0) return;
    e.stopPropagation();

    if (activeTool === 'pan' || isSpacePressed.current) {
        setAction({ type: 'panning', startPos: { x: e.clientX, y: e.clientY }, startVT: viewTransform });
        return;
    }

    setSelectedShapeId(shape.id);
    const pos = getMousePos(e);
    
    const bbox = getShapeBBox(shape);
    
    if (type === 'shape') {
        setAction({ type: 'dragging', shapeId: shape.id, startOffset: {x: pos.x - bbox.x, y: pos.y - bbox.y} });
    } else if (type === 'resize' && (shape.type === 'bubble' || shape.type === 'image' || shape.type === 'text') && typeof handle === 'string') {
        setAction({ type: 'resizing', shapeId: shape.id, handle, originalShape: shape as BubbleShape | ImageShape | TextShape });
    } else if (type === 'tail' && shape.type === 'bubble') {
        setAction({ type: 'draggingTail', shapeId: shape.id });
    } else if (type === 'panelVertex' && shape.type === 'panel' && typeof handle === 'number') {
        setAction({ type: 'editingPanelVertex', shapeId: shape.id, vertexIndex: handle });
    } else if (type === 'arrowHandle' && shape.type === 'arrow' && typeof handle === 'number') {
        setAction({ type: 'draggingArrowHandle', shapeId: shape.id, handleIndex: handle as 0 | 1 });
    }
  };

  const handleAddPanelVertex = (e: React.MouseEvent, shape: PanelShape, edgeIndex: number) => {
    e.stopPropagation();
    if (activeTool !== 'select') return;

    const p1 = shape.points[edgeIndex];
    const p2 = shape.points[(edgeIndex + 1) % shape.points.length];
    const newPoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };

    const newPoints = [...shape.points];
    newPoints.splice(edgeIndex + 1, 0, newPoint);

    onShapesChange(
        shapes.map(s => (s.id === shape.id && s.type === 'panel' ? { ...s, points: newPoints } : s)),
        true
    );

    // Immediately start dragging the new point
    setAction({
        type: 'editingPanelVertex',
        shapeId: shape.id,
        vertexIndex: edgeIndex + 1,
    });
};
  
  const handleShapeDoubleClick = (e: React.MouseEvent, shape: CanvasShape) => {
    if (shape.type === 'text' || shape.type === 'bubble') {
        e.stopPropagation();
        setEditingShapeId(shape.id);
    }
  };

  const renderPose = (imageShape: ImageShape) => {
    if (!imageShape.pose) return null;
    const { pose } = imageShape;

    if (pose.type === 'skeleton') {
        const { data, preset } = pose;

        const allJointKeys = Object.keys(createInitialSkeleton(0, 0, 0, 0));
        const presetJoints: Record<SkeletonPose['preset'], string[]> = {
            face: ['head', 'leftEye', 'rightEye', 'nose', 'mouth'],
            upper: ['head', 'neck', 'leftShoulder', 'rightShoulder', 'leftElbow', 'rightElbow', 'leftHand', 'rightHand', 'hips', 'leftEye', 'rightEye', 'nose', 'mouth'],
            lower: ['hips', 'leftHip', 'rightHip', 'leftKnee', 'rightKnee', 'leftFoot', 'rightFoot'],
            full: allJointKeys,
        };
        const visibleJoints = new Set(presetJoints[preset || 'full']);
        const visibleConnections = skeletonConnections.filter(([start, end]) => visibleJoints.has(start) && visibleJoints.has(end));
        
        return (
            <g pointerEvents="none" opacity="0.8">
                {visibleConnections.map(([start, end]) => {
                    if(!data[start] || !data[end]) return null;
                    return <line key={`${start}-${end}`} x1={data[start].x} y1={data[start].y} x2={data[end].x} y2={data[end].y} stroke="#00BFFF" strokeWidth={4/viewTransform.scale} strokeLinecap='round'/>
                })}
                {Object.entries(data).filter(([key]) => visibleJoints.has(key)).map(([key, pos]) => {
                    if (!pos) return null;
                    return <circle key={key} cx={pos.x} cy={pos.y} r={6/viewTransform.scale} fill={key === 'head' ? '#FF4500' : '#FF00FF'} stroke="white" strokeWidth={1/viewTransform.scale} />
                })}
            </g>
        )
    }
    if (pose.type === 'image') {
        return <image href={pose.href} x={imageShape.x} y={imageShape.y} width={imageShape.width} height={imageShape.height} opacity="0.8" pointerEvents="none" />;
    }
    if (pose.type === 'drawing') {
        const transformedPoints = pose.points.map(stroke =>
            stroke.map(p => ({
                x: imageShape.x + p.x * imageShape.width,
                y: imageShape.y + p.y * imageShape.height,
            }))
        );
        const pathData = transformedPoints.map(stroke => stroke.map((p, i) => (i === 0 ? 'M' : 'L') + `${p.x},${p.y}`).join(' ')).join(' ');
        return <path d={pathData} fill="none" stroke="red" strokeWidth={3/viewTransform.scale} strokeLinecap="round" strokeLinejoin="round" opacity="0.8" pointerEvents="none" />
    }
    return null;
  }

  useImperativeHandle(ref, () => ({
    getLayoutAsImage: async (includeCharacters: boolean, charactersToDraw: Character[]): Promise<string> => {
        return new Promise((resolve, reject) => {
            const originalSelectedId = selectedShapeId;
            setSelectedShapeId(null);
            setTimeout(() => {
                const svg = svgRef.current;
                if (!svg) return reject("SVG element not found");
                
                const tempSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
                tempSvg.setAttribute("width", canvasConfig.w.toString());
                tempSvg.setAttribute("height", canvasConfig.h.toString());
                tempSvg.setAttribute("viewBox", `0 0 ${canvasConfig.w} ${canvasConfig.h}`);
                
                const style = document.createElement('style');
                style.textContent = `text { font-family: sans-serif; }`;
                tempSvg.appendChild(style);

                const contentGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
                
                // Draw panels first
                shapes.filter(s => s.type === 'panel').forEach((shape, index) => {
                    const panel = shape as PanelShape;
                    const p = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
                    p.setAttribute('points', panel.points.map(pt => `${pt.x},${pt.y}`).join(' '));
                    p.setAttribute('fill', 'none');
                    p.setAttribute('stroke', 'black');
                    p.setAttribute('stroke-width', '4');
                    contentGroup.appendChild(p);

                    const centroid = getPolygonCentroid(panel.points);
                    const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
                    text.setAttribute('x', String(centroid.x));
                    text.setAttribute('y', String(centroid.y));
                    text.setAttribute('font-size', '60');
                    text.setAttribute('font-weight', 'bold');
                    text.setAttribute('fill', 'rgba(0,0,0,0.2)');
                    text.setAttribute('text-anchor', 'middle');
                    text.setAttribute('dominant-baseline', 'central');
                    text.textContent = String(index + 1);
                    contentGroup.appendChild(text);
                });
                
                // Draw characters and poses
                if (includeCharacters) {
                    shapes.filter(s => s.type === 'image').forEach(shape => {
                        const imgShape = shape as ImageShape;
                        const character = charactersToDraw.find(c => c.id === imgShape.characterId);
                        
                        const { pose } = imgShape;
                        if (pose) {
                          if (pose.type === 'skeleton') {
                              const { data } = pose;
                              skeletonConnections.forEach(([start, end]) => {
                                  if(!data[start] || !data[end]) return;
                                  const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
                                  line.setAttribute('x1', String(data[start]!.x));
                                  line.setAttribute('y1', String(data[start]!.y));
                                  line.setAttribute('x2', String(data[end]!.x));
                                  line.setAttribute('y2', String(data[end]!.y));
                                  line.setAttribute('stroke', '#00BFFF');
                                  line.setAttribute('stroke-width', '4');
                                  line.setAttribute('stroke-linecap', 'round');
                                  contentGroup.appendChild(line);
                              });
                              Object.entries(data).forEach(([key, pos]) => {
                                  if(!pos) return;
                                  const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
                                  circle.setAttribute('cx', String(pos.x));
                                  circle.setAttribute('cy', String(pos.y));
                                  circle.setAttribute('r', '6');
                                  circle.setAttribute('fill', key === 'head' ? '#FF4500' : '#FF00FF');
                                  contentGroup.appendChild(circle);
                              });
                          } else if (pose.type === 'image') {
                              const image = document.createElementNS("http://www.w3.org/2000/svg", "image");
                              image.setAttribute('href', pose.href);
                              image.setAttribute('x', String(imgShape.x));
                              image.setAttribute('y', String(imgShape.y));
                              image.setAttribute('width', String(imgShape.width));
                              image.setAttribute('height', String(imgShape.height));
                              image.setAttribute('opacity', '0.8');
                              contentGroup.appendChild(image);
                          } else if (pose.type === 'drawing') {
                              const transformedPoints = pose.points.map(stroke =>
                                  stroke.map(p => ({
                                      x: imgShape.x + p.x * imgShape.width,
                                      y: imgShape.y + p.y * imgShape.height,
                                  }))
                              );
                              const pathData = transformedPoints.map(stroke => stroke.map((p, i) => (i === 0 ? 'M' : 'L') + `${p.x},${p.y}`).join(' ')).join(' ');
                              const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
                              path.setAttribute('d', pathData);
                              path.setAttribute('fill', 'none');
                              path.setAttribute('stroke', 'red');
                              path.setAttribute('stroke-width', '3');
                              path.setAttribute('stroke-linecap', 'round');
                              path.setAttribute('stroke-linejoin', 'round');
                              path.setAttribute('opacity', '0.8');
                              contentGroup.appendChild(path);
                          }
                        }

                        if (character) {
                          const nameLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
                          const bbox = getShapeBBox(imgShape);
                          nameLabel.setAttribute('x', String(bbox.x + bbox.width / 2));
                          nameLabel.setAttribute('y', String(bbox.y + bbox.height / 2));
                          nameLabel.setAttribute('font-size', '20');
                          nameLabel.setAttribute('font-weight', 'bold');
                          nameLabel.setAttribute('fill', 'white');
                          nameLabel.setAttribute('stroke', 'black');
                          nameLabel.setAttribute('stroke-width', '1');
                          nameLabel.setAttribute('paint-order', 'stroke');
                          nameLabel.setAttribute('text-anchor', 'middle');
                          nameLabel.setAttribute('dominant-baseline', 'central');
                          nameLabel.textContent = character.name;
                          contentGroup.appendChild(nameLabel);
                        }

                        if (imgShape.pose?.type === 'skeleton' && imgShape.pose.comment) {
                            const commentLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
                            const bbox = getShapeBBox(imgShape);
                            commentLabel.setAttribute('x', String(bbox.x + bbox.width / 2));
                            commentLabel.setAttribute('y', String(bbox.y + bbox.height + 20));
                            commentLabel.setAttribute('font-size', '18');
                            commentLabel.setAttribute('font-weight', 'bold');
                            commentLabel.setAttribute('fill', 'green');
                            commentLabel.setAttribute('text-anchor', 'middle');
                            commentLabel.textContent = `(${imgShape.pose.comment})`;
                            contentGroup.appendChild(commentLabel);
                        }
                    });
                }
                
                 // Draw bubbles
                shapes.filter(s => s.type === 'bubble').forEach(shape => {
                    const bubble = shape as BubbleShape;
                    const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
                    p.setAttribute('d', getBubblePath(bubble));
                    p.setAttribute('fill', 'white');
                    p.setAttribute('stroke', 'black');
                    p.setAttribute('stroke-width', '3');
                    contentGroup.appendChild(p);

                    const fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
                    fo.setAttribute('x', String(bubble.x + 10));
                    fo.setAttribute('y', String(bubble.y + 10));
                    fo.setAttribute('width', String(bubble.width - 20));
                    fo.setAttribute('height', String(bubble.height - 20));

                    const div = document.createElement('div');
                    div.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
                    div.style.height = '100%';
                    div.style.overflow = 'hidden';
                    div.style.wordWrap = 'break-word';
                    div.style.fontSize = '20px';
                    div.style.lineHeight = '1.2';
                    div.style.fontFamily = 'sans-serif';
                    div.textContent = bubble.text;

                    fo.appendChild(div);
                    contentGroup.appendChild(fo);
                });

                // Draw standalone text
                shapes.filter(s => s.type === 'text').forEach(shape => {
                    const textShape = shape as TextShape;
                    const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
                    text.setAttribute('x', String(textShape.x));
                    text.setAttribute('y', String(textShape.y));
                    text.setAttribute('font-size', String(textShape.fontSize));
                    text.setAttribute('font-weight', 'bold');
                    text.setAttribute('fill', 'black');
                    text.setAttribute('text-anchor', 'start');
                    text.setAttribute('dominant-baseline', 'hanging');
                    text.style.fontFamily = 'sans-serif';
                    text.textContent = textShape.text;
                    contentGroup.appendChild(text);
                });

                tempSvg.appendChild(contentGroup);
                const svgData = new XMLSerializer().serializeToString(tempSvg);
                const canvas = document.createElement("canvas");
                canvas.width = canvasConfig.w;
                canvas.height = canvasConfig.h;
                const ctx = canvas.getContext("2d");
                if (!ctx) return reject("Canvas context not available");
                
                const img = new Image();
                img.onload = () => {
                    ctx.fillStyle = '#FFFFFF';
                    ctx.fillRect(0, 0, canvas.width, canvas.height);
                    ctx.drawImage(img, 0, 0, canvasConfig.w, canvasConfig.h);
                    resolve(canvas.toDataURL("image/png"));
                    setSelectedShapeId(originalSelectedId);
                };
                img.onerror = () => {
                    reject("Failed to load SVG image");
                    setSelectedShapeId(originalSelectedId);
                }
                img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgData)));
            }, 50);
        });
    }
  }));
  
  const clearCanvas = () => onShapesChange([]);
  
  const handleDragOver = (e: React.DragEvent) => e.preventDefault();

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    const characterId = e.dataTransfer.getData('characterId');
    const character = characters.find(c => c.id === characterId);
    if (!character) return;

    const dropPointWorld = getMousePos(e);

    const panels = shapes.filter(s => s.type === 'panel') as PanelShape[];
    let droppedOnPanelIndex = -1;

    for (let i = panels.length - 1; i >= 0; i--) {
        const p = panels[i];
        if (isPointInPolygon(dropPointWorld, p.points)) {
            droppedOnPanelIndex = i;
            break;
        }
    }

    if (droppedOnPanelIndex !== -1) {
        const charWidth = 150;
        const charHeight = 250;
        const charX = dropPointWorld.x - charWidth / 2;
        const charY = dropPointWorld.y - charHeight / 2;
        const newImageShape: ImageShape = {
            id: Date.now().toString(),
            type: 'image',
            href: character.sheetImage,
            characterId: character.id,
            panelIndex: droppedOnPanelIndex,
            x: charX,
            y: charY,
            width: charWidth,
            height: charHeight,
            pose: {
                type: 'skeleton',
                preset: 'full',
                data: createInitialSkeleton(charX, charY, charWidth, charHeight),
                comment: ''
            }
        };
        onShapesChange([...shapes, newImageShape]);
    } else {
        // You could add a temporary message here to guide the user
    }
  };
  
  const isPointInPolygon = (point: {x:number, y:number}, polygon: {x:number, y:number}[]) => {
    let isInside = false;
    for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
        const xi = polygon[i].x, yi = polygon[i].y;
        const xj = polygon[j].x, yj = polygon[j].y;
        const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
        if (intersect) isInside = !isInside;
    }
    return isInside;
  };
  
  const addBubbleTail = (shapeId: string) => {
      onShapesChange(shapes.map(s => {
          if (s.id === shapeId && s.type === 'bubble') {
              const bbox = getShapeBBox(s);
              return {...s, tail: { x: bbox.x + bbox.width / 2, y: bbox.y + bbox.height + 30 } };
          }
          return s;
      }));
  }

  const removeBubbleTail = (shapeId: string) => {
    onShapesChange(shapes.map(s => (s.id === shapeId && s.type === 'bubble') ? {...s, tail: undefined} : s));
  };
  
  const openPoseEditor = (shape: ImageShape) => {
    setPosingCharacter(shape);
  };

  const savePose = (characterId: string, pose: Pose) => {
    onShapesChange(shapes.map(s => {
      if (s.id === characterId && s.type === 'image') {
        const originalShape = s as ImageShape;
        if (pose?.type === 'skeleton') {
            const modalCharBox = { x: 0, y: 0, w: 200, h: 400 }; // As defined in PoseEditorModal
            const newSkeletonData: SkeletonData = {};
            for (const key in pose.data) {
                const modalPoint = pose.data[key as keyof SkeletonData];
                if (!modalPoint) continue;
                newSkeletonData[key as keyof SkeletonData] = {
                    x: originalShape.x + (modalPoint.x - modalCharBox.x) / modalCharBox.w * originalShape.width,
                    y: originalShape.y + (modalPoint.y - modalCharBox.y) / modalCharBox.h * originalShape.height,
                };
            }
            return {...originalShape, pose: {...pose, data: newSkeletonData}};
        }
        return {...originalShape, pose};
      }
      return s;
    }));
    setPosingCharacter(null);
}

  const changeFontSize = (shapeId: string, amount: number) => {
      onShapesChange(shapes.map(s => {
          if (s.id === shapeId && s.type === 'text') {
              return {...s, fontSize: Math.max(8, s.fontSize + amount)};
          }
          return s;
      }));
  }
  
    const handleImageFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (!file) return;

        const reader = new FileReader();
        reader.onloadend = () => {
            const base64 = reader.result as string;
            const img = new Image();
            img.onload = () => {
                const svg = svgRef.current;
                if (!svg) return;

                const { width: viewWidth, height: viewHeight } = svg.getBoundingClientRect();
                const viewCenterX = (viewWidth / 2 - viewTransform.x) / viewTransform.scale;
                const viewCenterY = (viewHeight / 2 - viewTransform.y) / viewTransform.scale;

                const defaultWidth = 200;
                const scale = defaultWidth / img.width;
                const defaultHeight = img.height * scale;

                const newImageShape: ImageShape = {
                    id: Date.now().toString(),
                    type: 'image',
                    href: base64,
                    characterId: 'user-upload',
                    panelIndex: -1,
                    x: viewCenterX - defaultWidth / 2,
                    y: viewCenterY - defaultHeight / 2,
                    width: defaultWidth,
                    height: defaultHeight,
                };
                onShapesChange([...shapes, newImageShape]);
            };
            img.src = base64;
        };
        reader.readAsDataURL(file);

        // Reset file input
        e.target.value = '';
    };


  const editingShape = shapes.find(s => s.id === editingShapeId);
  const editingShapeSVGPos = svgRef.current && editingShape ? (() => {
      const bbox = getShapeBBox(editingShape);
      const CTM = svgRef.current.getScreenCTM();
      if (!CTM) return { x: 0, y: 0, width: 0, height: 0, fontSize: 16 };
      const svgRect = svgRef.current.getBoundingClientRect();
      const scale = CTM.a; // Use CTM scale directly, viewTransform is handled by SVG
      
      const textShape = editingShape.type === 'text' ? editingShape as TextShape : null;
      const fontSize = textShape ? textShape.fontSize * viewTransform.scale * scale : 16 * viewTransform.scale * scale;

      return {
          x: svgRect.left + (bbox.x * viewTransform.scale + viewTransform.x) * scale,
          y: svgRect.top + (bbox.y * viewTransform.scale + viewTransform.y) * scale,
          width: bbox.width * viewTransform.scale * scale,
          height: bbox.height * viewTransform.scale * scale,
          fontSize
      }
  })() : null;
  
  const panels = shapes.filter(s => s.type === 'panel');

  const handleWheel = (e: React.WheelEvent) => {
      e.preventDefault();
      const svg = svgRef.current;
      if (!svg) return;
      
      const rect = svg.getBoundingClientRect();
      const mouseX = e.clientX - rect.left;
      const mouseY = e.clientY - rect.top;

      const scaleFactor = 1.1;
      const newScale = e.deltaY < 0 ? viewTransform.scale * scaleFactor : viewTransform.scale / scaleFactor;
      const clampedScale = Math.max(0.1, Math.min(newScale, 10));
      
      const newX = mouseX - (mouseX - viewTransform.x) * (clampedScale / viewTransform.scale);
      const newY = mouseY - (mouseY - viewTransform.y) * (clampedScale / viewTransform.scale);

      onViewTransformChange({ scale: clampedScale, x: newX, y: newY });
  };
  
    const handleTooltipShow = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
        const rect = e.currentTarget.getBoundingClientRect();
        setTooltip({
            text,
            x: rect.right + 10,
            y: rect.top + rect.height / 2 - 14,
        });
    };

    const handleTooltipHide = () => {
        setTooltip(null);
    };

    const zoom = (direction: 'in' | 'out') => {
        const svg = svgRef.current;
        if (!svg) return;
        const { width: viewWidth, height: viewHeight } = svg.getBoundingClientRect();
        const centerX = viewWidth / 2;
        const centerY = viewHeight / 2;

        const scaleFactor = 1.25;
        const newScale = direction === 'in' ? viewTransform.scale * scaleFactor : viewTransform.scale / scaleFactor;
        const clampedScale = Math.max(0.1, Math.min(newScale, 10));

        const newX = centerX - (centerX - viewTransform.x) * (clampedScale / viewTransform.scale);
        const newY = centerY - (centerY - viewTransform.y) * (clampedScale / viewTransform.scale);
        onViewTransformChange({ scale: clampedScale, x: newX, y: newY });
    };
    const zoomIn = () => zoom('in');
    const zoomOut = () => zoom('out');

    const shapeOrder = useMemo(() => ({
        panel: 10,
        drawing: 20,
        arrow: 25,
        image: 30, // Characters
        bubble: 40,
        text: 50, // Text on top
    }), []);
    const sortedShapes = useMemo(() => [...shapes].sort((a, b) => (shapeOrder[a.type] || 99) - (shapeOrder[b.type] || 99)), [shapes, shapeOrder]);

  return (
    <div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm h-full flex flex-col relative" onDragOver={handleDragOver} onDrop={handleDrop}>
        <input type="file" ref={imageUploadRef} onChange={handleImageFileSelect} accept="image/png, image/jpeg, image/webp" className="hidden"/>
        {tooltip && (
            <div style={{ position: 'fixed', left: tooltip.x, top: tooltip.y, zIndex: 100 }} className="bg-gray-800 text-white text-xs px-2 py-1 rounded-md shadow-lg pointer-events-none transition-opacity duration-150">
                {tooltip.text}
            </div>
        )}
        {isDraggingCharacter && (
            <div className="absolute inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 pointer-events-none rounded-xl">
                <p className="text-white text-2xl font-bold bg-indigo-600/80 px-6 py-3 rounded-lg">{t('dropCharacterOnPanel')}</p>
            </div>
        )}
        {posingCharacter && <PoseEditorModal character={posingCharacter} onSave={savePose} onClose={() => setPosingCharacter(null)} />}
        {editingShapeId && editingShape && editingShapeSVGPos && (
             <textarea ref={textEditRef} value={editingShape.text} onChange={(e) => onShapesChange(shapes.map(s => s.id === editingShapeId ? {...s, text: e.target.value} : s), false)} onBlur={() => { onShapesChange(shapes, true); setEditingShapeId(null); } } onKeyDown={(e) => e.key === 'Enter' && e.shiftKey ? null : e.key === 'Escape' || e.key === 'Enter' ? setEditingShapeId(null) : null} className="absolute z-50 p-2 border-2 border-indigo-500 rounded-md bg-white resize-none shadow-lg" style={{ left: `${editingShapeSVGPos.x}px`, top: `${editingShapeSVGPos.y}px`, width: `${editingShapeSVGPos.width}px`, height: `${editingShapeSVGPos.height}px`, fontFamily: 'sans-serif', fontSize: `${editingShapeSVGPos.fontSize}px`, lineHeight: 1.2, outline: 'none' }}/>
        )}
        <div className="flex justify-between items-center mb-4 px-2">
            <h2 className="text-lg font-semibold text-gray-700">{t('createVisualLayout')}</h2>
            <button onClick={onToggleFullscreen} className="p-2 rounded-full hover:bg-gray-200" onMouseEnter={(e) => handleTooltipShow(e, isFullscreen ? t('fullscreenExit') : t('fullscreenEnter'))} onMouseLeave={handleTooltipHide}>
                {isFullscreen ? <ShrinkIcon className="w-5 h-5" /> : <ExpandIcon className="w-5 h-5" />}
            </button>
        </div>
      <div className="flex-grow w-full h-full flex items-start justify-center gap-4 bg-gray-100 rounded-lg overflow-hidden p-4 relative">
        {/* Toolbar */}
        <div className="bg-white shadow-lg rounded-full border border-gray-200 p-2 flex flex-col items-center gap-1 z-10">
            <button onClick={onUndo} disabled={!canUndo} className="p-3 rounded-full hover:bg-gray-200 text-gray-700 disabled:text-gray-300 disabled:cursor-not-allowed" onMouseEnter={(e) => handleTooltipShow(e, t('undo'))} onMouseLeave={handleTooltipHide}><UndoIcon className="w-5 h-5"/></button>
            <button onClick={onRedo} disabled={!canRedo} className="p-3 rounded-full hover:bg-gray-200 text-gray-700 disabled:text-gray-300 disabled:cursor-not-allowed" onMouseEnter={(e) => handleTooltipShow(e, t('redo'))} onMouseLeave={handleTooltipHide}><RedoIcon className="w-5 h-5"/></button>
            <div className="w-8 h-px bg-gray-300 my-1"></div>
            <button onClick={() => { setActiveTool('select'); }} className={`p-3 rounded-full ${activeTool === 'select' ? 'bg-indigo-600 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('selectAndMove'))} onMouseLeave={handleTooltipHide}><SelectIcon className="w-5 h-5"/></button>
            <button onClick={() => setActiveTool('pan')} className={`p-3 rounded-full ${activeTool === 'pan' ? 'bg-indigo-600 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('panCanvas'))} onMouseLeave={handleTooltipHide}><HandIcon className="w-5 h-5"/></button>
            <div className="w-8 h-px bg-gray-300 my-1"></div>
            <button onClick={() => setActiveTool('panel')} className={`p-3 rounded-full ${activeTool === 'panel' ? 'bg-indigo-600 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('drawPanel'))} onMouseLeave={handleTooltipHide}><PolygonIcon className="w-5 h-5"/></button>
            <button onClick={() => setActiveTool('text')} className={`p-3 rounded-full ${activeTool === 'text' ? 'bg-indigo-600 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('addText'))} onMouseLeave={handleTooltipHide}><TextToolIcon className="w-5 h-5"/></button>
            <button onClick={() => setActiveTool('draw')} className={`p-3 rounded-full ${activeTool === 'draw' ? 'bg-indigo-600 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('drawFreehand'))} onMouseLeave={handleTooltipHide}><BrushIcon className="w-5 h-5"/></button>
            <button onClick={() => setActiveTool('arrow')} className={`p-3 rounded-full ${activeTool === 'arrow' ? 'bg-indigo-600 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('drawArrow'))} onMouseLeave={handleTooltipHide}><ArrowIcon className="w-5 h-5"/></button>
            <button onClick={() => imageUploadRef.current?.click()} className="p-3 rounded-full hover:bg-gray-200 text-gray-700" onMouseEnter={(e) => handleTooltipShow(e, t('uploadPose'))} onMouseLeave={handleTooltipHide}><UploadIcon className="w-5 h-5"/></button>
            
            <div className="w-8 h-px bg-gray-300 my-1"></div>
            
            <div className="flex flex-col items-center gap-1 bg-gray-100 rounded-full p-1">
                <button onClick={() => {setActiveTool('bubble'); setActiveBubbleType('rounded')}} className={`p-2 rounded-full ${activeTool === 'bubble' && activeBubbleType === 'rounded' ? 'bg-indigo-500 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('roundedBubble'))} onMouseLeave={handleTooltipHide}><BubbleToolIcon className="w-5 h-5"/></button>
                <button onClick={() => {setActiveTool('bubble'); setActiveBubbleType('oval')}} className={`p-2 rounded-full ${activeTool === 'bubble' && activeBubbleType === 'oval' ? 'bg-indigo-500 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('ovalBubble'))} onMouseLeave={handleTooltipHide}><CircleIcon className="w-5 h-5"/></button>
                <button onClick={() => {setActiveTool('bubble'); setActiveBubbleType('rect')}} className={`p-2 rounded-full ${activeTool === 'bubble' && activeBubbleType === 'rect' ? 'bg-indigo-500 text-white' : 'hover:bg-gray-200 text-gray-700'}`} onMouseEnter={(e) => handleTooltipShow(e, t('rectangularBubble'))} onMouseLeave={handleTooltipHide}><SquareIcon className="w-5 h-5"/></button>
            </div>

            <div className="w-8 h-px bg-gray-300 my-1"></div>
            <button onClick={clearCanvas} className="p-3 rounded-full hover:bg-red-500 hover:text-white text-gray-700" onMouseEnter={(e) => handleTooltipShow(e, t('clearCanvas'))} onMouseLeave={handleTooltipHide}><TrashIcon className="w-5 h-5"/></button>
        </div>
        
        {/* Brush Controls */}
        {(activeTool === 'draw' || activeTool === 'arrow') && (
            <div className="absolute top-6 left-24 z-10 bg-white shadow-lg rounded-lg border border-gray-200 p-2 flex items-center gap-3 animate-fade-in">
                <label htmlFor="brush-color" className="text-sm font-medium text-gray-700">{t('brushColor')}</label>
                <input id="brush-color" type="color" value={brushColor} onChange={(e) => setBrushColor(e.target.value)} className="w-8 h-8 p-0 border-none rounded cursor-pointer bg-white" style={{ WebkitAppearance: 'none', MozAppearance: 'none', appearance: 'none' }} />
                <label htmlFor="brush-size" className="text-sm font-medium text-gray-700 ml-2">{t('brushSize')}</label>
                <div className="flex items-center gap-2">
                    <input id="brush-size" type="range" min="1" max="50" value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} className="w-24" />
                    <span className="text-sm w-6 text-center font-semibold text-gray-600">{brushSize}</span>
                </div>
            </div>
        )}

        {/* Canvas */}
        <div className="w-full h-full relative" onWheel={handleWheel}>
            <svg
                ref={svgRef} width="100%" height="100%"
                className={`${isSpacePressed.current || action.type === 'panning' ? 'cursor-grabbing' : activeTool === 'pan' ? 'cursor-grab' : 'cursor-default'}`}
                style={{ cursor: (activeTool === 'draw' || activeTool === 'arrow') ? 'none' : (isSpacePressed.current || action.type === 'panning' ? 'grabbing' : activeTool === 'pan' ? 'grab' : activeTool === 'select' ? 'default' : 'crosshair')}}
                onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={() => { handleMouseUp(null as any); setCursorPreview(null); }}
            >
                <defs>
                  <marker id="arrowhead" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
                    <path d="M 0 0 L 10 5 L 0 10 z" fill="#FF0000" />
                  </marker>
                   <marker id="arrowhead-selected" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
                    <path d="M 0 0 L 10 5 L 0 10 z" fill="rgba(128, 90, 213, 1)" />
                  </marker>
                </defs>
                <g className="canvas-content" transform={`translate(${viewTransform.x}, ${viewTransform.y}) scale(${viewTransform.scale})`}>
                    {proposalImage && (
                         <image 
                            href={proposalImage}
                            x="0" y="0"
                            width={canvasConfig.w} height={canvasConfig.h}
                            opacity={isProposalVisible ? proposalOpacity : 0}
                            style={{ pointerEvents: 'none' }}
                         />
                    )}
                    <rect 
                        id="canvas-background-rect"
                        x="0" y="0" 
                        width={canvasConfig.w} height={canvasConfig.h} 
                        fill={proposalImage ? 'transparent' : 'white'}
                        stroke="rgba(0,0,0,0.15)" strokeWidth="1" vectorEffect="non-scaling-stroke"
                        style={{
                            boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
                        }}
                    />
                     {drawingGuideRect && (
                        <rect
                            x={drawingGuideRect.x}
                            y={drawingGuideRect.y}
                            width={drawingGuideRect.width}
                            height={drawingGuideRect.height}
                            fill="none"
                            stroke="rgba(128, 90, 213, 0.5)"
                            strokeWidth={2 / viewTransform.scale}
                            strokeDasharray={`${4 / viewTransform.scale}`}
                            pointerEvents="none"
                        />
                     )}
                {sortedShapes.map((shape) => {
                    const isSelected = selectedShapeId === shape.id;
                    const panelIndex = shape.type === 'panel' ? panels.findIndex(p => p.id === shape.id) : -1;
                    const bbox = getShapeBBox(shape);
                    const isDrawingToolActive = activeTool === 'panel' || activeTool === 'bubble' || activeTool === 'draw' || activeTool === 'arrow';
                    
                    return (
                        <g 
                            key={shape.id} 
                            data-shape-type={shape.type} 
                            onMouseDown={(e) => handleShapeInteraction(e, shape, 'shape')} 
                            onDoubleClick={(e) => handleShapeDoubleClick(e, shape)} 
                            style={{ 
                                cursor: activeTool === 'select' ? 'move' : 'default',
                                pointerEvents: isDrawingToolActive ? 'none' : 'auto'
                            }}
                        >
                            {shape.type === 'panel' && (
                                <>
                                <polygon points={shape.points.map(p => `${p.x},${p.y}`).join(' ')} fill="rgba(128, 90, 213, 0.1)" stroke="rgba(128, 90, 213, 0.8)" strokeWidth={isSelected ? 4/viewTransform.scale : 2/viewTransform.scale} onMouseDown={(e) => handleShapeInteraction(e, shape, 'shape')} />
                                {isSelected && activeTool === 'select' && (
                                    <>
                                        {shape.points.map((p, i) => (
                                            <circle key={i} cx={p.x} cy={p.y} r={6/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={2/viewTransform.scale} cursor="move" onMouseDown={(e) => handleShapeInteraction(e, shape, 'panelVertex', i)} />
                                        ))}
                                        {shape.points.map((p1, i) => {
                                            const p2 = shape.points[(i + 1) % shape.points.length];
                                            const midX = (p1.x + p2.x) / 2;
                                            const midY = (p1.y + p2.y) / 2;
                                            return <circle key={`edge-${i}`} cx={midX} cy={midY} r={5/viewTransform.scale} fill="rgba(128, 90, 213, 1)" cursor="copy" onMouseDown={(e) => handleAddPanelVertex(e, shape, i)} />
                                        })}
                                    </>
                                )}
                                {panelIndex !== -1 && (
                                    <text x={getPolygonCentroid(shape.points).x} y={getPolygonCentroid(shape.points).y} fontSize={60/viewTransform.scale} fontWeight="bold" fill="rgba(0,0,0,0.1)" textAnchor="middle" dominantBaseline="central" pointerEvents="none">{panelIndex + 1}</text>
                                )}
                                </>
                            )}
                            {shape.type === 'text' && (
                                <>
                                    {/* Transparent rect for hit detection */}
                                    <rect
                                        x={bbox.x}
                                        y={bbox.y}
                                        width={bbox.width}
                                        height={bbox.height}
                                        fill="transparent"
                                    />
                                    <text
                                        x={shape.x} y={shape.y}
                                        fontSize={shape.fontSize}
                                        fill="black"
                                        style={{ fontFamily: 'sans-serif', userSelect: 'none', WebkitUserSelect: 'none' }}
                                        pointerEvents="none"
                                        dominantBaseline="hanging"
                                    >
                                        {shape.text}
                                    </text>
                                    {isSelected && (
                                        <>
                                            <rect x={bbox.x} y={bbox.y} width={bbox.width} height={bbox.height} fill="none" stroke="rgba(128, 90, 213, 1)" strokeWidth={1 / viewTransform.scale} strokeDasharray={`${4/viewTransform.scale}`} pointerEvents="none" />
                                            <g transform={`translate(${bbox.x + bbox.width}, ${bbox.y + bbox.height})`}>
                                                <rect x={-5/viewTransform.scale} y={-5/viewTransform.scale} width={10/viewTransform.scale} height={10/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={1.5/viewTransform.scale} cursor="se-resize" onMouseDown={(e) => handleShapeInteraction(e, shape, 'resize', 'bottom-right')} />
                                            </g>
                                            <foreignObject x={bbox.x - 30/viewTransform.scale} y={bbox.y + bbox.height/2 - 25/viewTransform.scale} width={25/viewTransform.scale} height={50/viewTransform.scale}>
                                                <div className="flex flex-col h-full justify-around items-center">
                                                    <button onMouseDown={(e) => e.stopPropagation()} onClick={() => changeFontSize(shape.id, 2)} className="p-0.5 bg-white rounded-full shadow border hover:bg-gray-100 flex items-center justify-center">
                                                        <PlusIcon className="w-4 h-4" />
                                                    </button>
                                                     <button onMouseDown={(e) => e.stopPropagation()} onClick={() => changeFontSize(shape.id, -2)} className="p-0.5 bg-white rounded-full shadow border hover:bg-gray-100 flex items-center justify-center">
                                                        <MinusIcon className="w-4 h-4" />
                                                    </button>
                                                </div>
                                            </foreignObject>
                                        </>
                                    )}
                                </>
                            )}
                            {shape.type === 'bubble' && (
                                <>
                                    <path d={getBubblePath(shape)} fill="white" stroke="black" strokeWidth={2 / viewTransform.scale} />
                                    <foreignObject x={shape.x + 10} y={shape.y + 10} width={shape.width - 20} height={shape.height - 20} pointerEvents="none">
                                        <div style={{ height: '100%', overflow: 'hidden', wordWrap: 'break-word', fontSize: '20px', lineHeight: '1.2', fontFamily: 'sans-serif' }}>
                                            {shape.text}
                                        </div>
                                    </foreignObject>
                                    {isSelected && (
                                        <>
                                            <rect x={bbox.x} y={bbox.y} width={bbox.width} height={bbox.height} fill="none" stroke="rgba(128, 90, 213, 1)" strokeWidth={1 / viewTransform.scale} strokeDasharray={`${4/viewTransform.scale}`} />
                                            {['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(handle => {
                                                const x = handle.includes('right') ? bbox.x + bbox.width : bbox.x;
                                                const y = handle.includes('bottom') ? bbox.y + bbox.height : bbox.y;
                                                return <rect key={handle} x={x-5/viewTransform.scale} y={y-5/viewTransform.scale} width={10/viewTransform.scale} height={10/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={1.5/viewTransform.scale} cursor={`${handle.split('-')[1].startsWith('e') ? 'ew' : 'ns'}-resize`} onMouseDown={(e) => handleShapeInteraction(e, shape, 'resize', handle)} />
                                            })}
                                            {shape.tail && (
                                                <circle cx={shape.tail.x} cy={shape.tail.y} r={6/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={2/viewTransform.scale} cursor="move" onMouseDown={(e) => handleShapeInteraction(e, shape, 'tail')} />
                                            )}
                                            <foreignObject x={bbox.x + bbox.width / 2 - 50 / viewTransform.scale} y={bbox.y - 30 / viewTransform.scale} width={100/viewTransform.scale} height={25/viewTransform.scale}>
                                                <div className="flex justify-center items-center gap-1">
                                                    <button onClick={() => shape.tail ? removeBubbleTail(shape.id) : addBubbleTail(shape.id)} className="p-1 bg-white rounded-md shadow border hover:bg-gray-100">
                                                        <BubbleToolIcon className="w-4 h-4" />
                                                    </button>
                                                    <button onClick={() => deleteShape(shape.id)} className="p-1 bg-white rounded-md shadow border hover:bg-gray-100">
                                                        <TrashIcon className="w-4 h-4 text-red-500"/>
                                                    </button>
                                                </div>
                                            </foreignObject>
                                        </>
                                    )}
                                </>
                            )}
                            {shape.type === 'drawing' && (
                                <path d={getDrawingPath(shape)} fill="none" stroke={shape.strokeColor} strokeWidth={shape.strokeWidth / viewTransform.scale} strokeLinecap="round" strokeLinejoin="round" />
                            )}
                             {shape.type === 'arrow' && (
                                <>
                                <line
                                    x1={shape.points[0].x} y1={shape.points[0].y}
                                    x2={shape.points[1].x} y2={shape.points[1].y}
                                    stroke={isSelected ? 'rgba(128, 90, 213, 1)' : shape.strokeColor}
                                    strokeWidth={(isSelected ? Math.max(shape.strokeWidth, 4) : shape.strokeWidth) / viewTransform.scale}
                                    strokeLinecap="round"
                                    markerEnd={isSelected ? "url(#arrowhead-selected)" : "url(#arrowhead)"}
                                />
                                {isSelected && (
                                    <>
                                        <circle cx={shape.points[0].x} cy={shape.points[0].y} r={8/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={2/viewTransform.scale} cursor="move" onMouseDown={(e) => handleShapeInteraction(e, shape, 'arrowHandle', 0)} />
                                        <circle cx={shape.points[1].x} cy={shape.points[1].y} r={8/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={2/viewTransform.scale} cursor="move" onMouseDown={(e) => handleShapeInteraction(e, shape, 'arrowHandle', 1)} />
                                    </>
                                )}
                                </>
                            )}
                            {shape.type === 'image' && (
                                <>
                                    <image href={shape.href} x={shape.x} y={shape.y} width={shape.width} height={shape.height} style={{ imageRendering: 'pixelated' }} />
                                    {renderPose(shape)}
                                     {(() => {
                                        const character = characters.find(c => c.id === shape.characterId);
                                        if (!character) return null;
                                        return (
                                            <g pointerEvents="none">
                                                <text
                                                    x={bbox.x + bbox.width / 2}
                                                    y={bbox.y + bbox.height / 2}
                                                    fontSize={20 / viewTransform.scale}
                                                    fontWeight="bold"
                                                    fill="white"
                                                    stroke="black"
                                                    strokeWidth={1 / viewTransform.scale}
                                                    paintOrder="stroke"
                                                    textAnchor="middle"
                                                    dominantBaseline="central"
                                                    style={{ userSelect: 'none' }}
                                                >
                                                    {character.name}
                                                </text>
                                            </g>
                                        );
                                    })()}
                                    {isSelected && (
                                        <>
                                            <rect x={bbox.x} y={bbox.y} width={bbox.width} height={bbox.height} fill="none" stroke="rgba(128, 90, 213, 1)" strokeWidth={2 / viewTransform.scale} strokeDasharray={`${4/viewTransform.scale}`} />
                                             {['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(handle => {
                                                const x = handle.includes('right') ? bbox.x + bbox.width : bbox.x;
                                                const y = handle.includes('bottom') ? bbox.y + bbox.height : bbox.y;
                                                return <rect key={handle} x={x-5/viewTransform.scale} y={y-5/viewTransform.scale} width={10/viewTransform.scale} height={10/viewTransform.scale} fill="white" stroke="rgba(128, 90, 213, 1)" strokeWidth={1.5/viewTransform.scale} cursor={`${handle.startsWith('top') || handle.startsWith('bottom') ? 'ns' : 'ew'}-resize`} onMouseDown={(e) => handleShapeInteraction(e, shape, 'resize', handle)} />
                                            })}
                                            <foreignObject x={bbox.x + bbox.width / 2 - 50 / viewTransform.scale} y={bbox.y - 30 / viewTransform.scale} width={100/viewTransform.scale} height={25/viewTransform.scale}>
                                                <div className="flex justify-center items-center gap-1">
                                                    <button onClick={() => openPoseEditor(shape)} className="p-1 bg-white rounded-md shadow border hover:bg-gray-100">
                                                        <EditPoseIcon className="w-4 h-4" />
                                                    </button>
                                                    <button onClick={() => deleteShape(shape.id)} className="p-1 bg-white rounded-md shadow border hover:bg-gray-100">
                                                        <TrashIcon className="w-4 h-4 text-red-500"/>
                                                    </button>
                                                </div>
                                            </foreignObject>
                                        </>
                                    )}
                                </>
                            )}
                        </g>
                    )
                })}
                </g>
                {cursorPreview && (activeTool === 'draw' || activeTool === 'arrow') && (
                    <circle 
                        cx={cursorPreview.x * viewTransform.scale + viewTransform.x} 
                        cy={cursorPreview.y * viewTransform.scale + viewTransform.y} 
                        r={brushSize / 2} 
                        fill="none" 
                        stroke={activeTool === 'arrow' ? '#FF0000' : brushColor} 
                        strokeWidth="1.5" 
                        strokeDasharray="2 2"
                        pointerEvents="none" 
                    />
                )}
            </svg>
        </div>
         {/* Zoom & Proposal Controls */}
        <div className="absolute bottom-6 right-6 z-10 flex flex-col items-end gap-3">
             {proposalImage && (
                <div className="bg-white shadow-lg rounded-lg border border-gray-200 p-2 flex flex-col gap-2 animate-fade-in">
                    <div className="flex items-center justify-between text-sm font-medium text-gray-700 px-1">
                        <span>{t('assistantGuide')}</span>
                         <button onClick={() => onProposalSettingsChange({ isProposalVisible: !isProposalVisible })} className="p-1 rounded-full hover:bg-gray-200">
                            {isProposalVisible ? <EyeIcon className="w-4 h-4"/> : <EyeOffIcon className="w-4 h-4"/>}
                        </button>
                    </div>
                    {isProposalVisible && (
                       <>
                        <div className="flex items-center gap-2">
                             <span className="text-xs font-medium text-gray-500">{t('opacity')}</span>
                             <input type="range" min="0" max="1" step="0.05" value={proposalOpacity} onChange={(e) => onProposalSettingsChange({ proposalOpacity: parseFloat(e.target.value) })} className="w-24" />
                        </div>
                        <button onClick={onApplyLayout} className="w-full text-xs font-semibold bg-indigo-100 text-indigo-700 hover:bg-indigo-200 rounded-md py-1.5">{t('applyLayout')}</button>
                       </>
                    )}
                </div>
             )}
            <div className="bg-white shadow-lg rounded-full border border-gray-200 p-1 flex items-center gap-1">
                <button onClick={zoomOut} className="p-2 rounded-full hover:bg-gray-200 text-gray-700" onMouseEnter={(e) => handleTooltipShow(e, t('zoomOut'))} onMouseLeave={handleTooltipHide}><MinusIcon className="w-5 h-5"/></button>
                <button onClick={fitAndCenterCanvas} className="p-2 rounded-full hover:bg-gray-200 text-gray-700 text-xs font-semibold" onMouseEnter={(e) => handleTooltipShow(e, t('fitToScreen'))} onMouseLeave={handleTooltipHide}>{Math.round(viewTransform.scale * 100)}%</button>
                <button onClick={zoomIn} className="p-2 rounded-full hover:bg-gray-200 text-gray-700" onMouseEnter={(e) => handleTooltipShow(e, t('zoomIn'))} onMouseLeave={handleTooltipHide}><PlusIcon className="w-5 h-5"/></button>
            </div>
        </div>

      </div>
    </div>
  );
});
```

## /components/PoseEditorModal.tsx

```tsx path="/components/PoseEditorModal.tsx" 
import React, { useState, useEffect, useRef, useMemo } from 'react';
import type { ImageShape, Pose, SkeletonData, SkeletonPose } from '../types';
import { useLocalization } from '../hooks/useLocalization';
import { XIcon, UploadIcon, BrushIcon, TrashIcon, RedoIcon, EditPoseIcon } from './icons';

interface PoseEditorModalProps {
  character: ImageShape;
  onSave: (id: string, pose: Pose) => void;
  onClose: () => void;
}

const MODAL_WIDTH = 200;
const MODAL_HEIGHT = 400;

const createInitialSkeleton = (x: number, y: number, width: number, height: number): SkeletonData => {
    const centerX = x + width / 2;
    const topY = y + height * 0.15;
    const hipY = y + height * 0.5;
    const armY = y + height * 0.3;
    const legY = y + height * 0.9;
    const shoulderWidth = width * 0.2;
    const hipWidth = width * 0.15;
    const eyeY = topY - height * 0.03;
    const eyeDistX = width * 0.07;
    const noseY = topY;
    const mouthY = topY + height * 0.05;

    return {
        head: { x: centerX, y: topY }, neck: { x: centerX, y: armY },
        leftShoulder: { x: centerX - shoulderWidth, y: armY }, rightShoulder: { x: centerX + shoulderWidth, y: armY },
        leftElbow: { x: centerX - shoulderWidth * 1.5, y: hipY }, rightElbow: { x: centerX + shoulderWidth * 1.5, y: hipY },
        leftHand: { x: centerX - shoulderWidth * 1.2, y: legY - height * 0.1 }, rightHand: { x: centerX + shoulderWidth * 1.2, y: legY - height * 0.1 },
        hips: { x: centerX, y: hipY }, leftHip: { x: centerX - hipWidth, y: hipY }, rightHip: { x: centerX + hipWidth, y: hipY },
        leftKnee: { x: centerX - hipWidth, y: hipY + height * 0.2 }, rightKnee: { x: centerX + hipWidth, y: hipY + 0.2 },
        leftFoot: { x: centerX - hipWidth, y: legY }, rightFoot: { x: centerX + hipWidth, y: legY },
        leftEye: { x: centerX - eyeDistX, y: eyeY }, rightEye: { x: centerX + eyeDistX, y: eyeY },
        nose: { x: centerX, y: noseY }, mouth: { x: centerX, y: mouthY },
    };
};

const allJointKeys = Object.keys(createInitialSkeleton(0, 0, 0, 0));

const presetJoints: Record<SkeletonPose['preset'], string[]> = {
    face: ['head', 'leftEye', 'rightEye', 'nose', 'mouth'],
    upper: ['head', 'neck', 'leftShoulder', 'rightShoulder', 'leftElbow', 'rightElbow', 'leftHand', 'rightHand', 'hips', 'leftEye', 'rightEye', 'nose', 'mouth'],
    lower: ['hips', 'leftHip', 'rightHip', 'leftKnee', 'rightKnee', 'leftFoot', 'rightFoot'],
    full: allJointKeys,
};

const skeletonConnections = [
    ['head', 'neck'], ['neck', 'leftShoulder'], ['neck', 'rightShoulder'],
    ['leftShoulder', 'leftElbow'], ['leftElbow', 'leftHand'],
    ['rightShoulder', 'rightElbow'], ['rightElbow', 'rightHand'],
    ['neck', 'hips'],
    ['hips', 'leftHip'], ['hips', 'rightHip'],
    ['leftHip', 'leftKnee'], ['leftKnee', 'leftFoot'],
    ['rightHip', 'rightKnee'], ['rightKnee', 'rightFoot'],
    ['leftEye', 'rightEye'], ['nose', 'mouth'],
];

export function PoseEditorModal({ character, onSave, onClose }: PoseEditorModalProps) {
    const { t } = useLocalization();
    const [activeTab, setActiveTab] = useState<'skeleton' | 'upload' | 'draw'>('skeleton');
    
    const [skeletonPose, setSkeletonPose] = useState<SkeletonPose>(() => {
        if (character.pose?.type === 'skeleton') return { ...character.pose, preset: character.pose.preset || 'full' };
        const initialData = createInitialSkeleton(0, 0, MODAL_WIDTH, MODAL_HEIGHT);
        return { type: 'skeleton', preset: 'full', data: initialData, comment: '' };
    });
    const [draggingJoint, setDraggingJoint] = useState<string | null>(null);

    const [uploadedImage, setUploadedImage] = useState<string | null>(character.pose?.type === 'image' ? character.pose.href : null);
    const uploadRef = useRef<HTMLInputElement>(null);

    const drawCanvasRef = useRef<HTMLCanvasElement>(null);
    const [isDrawing, setIsDrawing] = useState(false);
    const [drawingPoints, setDrawingPoints] = useState<{x: number, y: number}[][]>(character.pose?.type === 'drawing' ? character.pose.points.map(stroke => stroke.map(p => ({ x: p.x * MODAL_WIDTH, y: p.y * MODAL_HEIGHT }))) : []);
    const [drawColor, setDrawColor] = useState('#FF0000');
    const [drawSize, setDrawSize] = useState(5);


    useEffect(() => {
        if (character.pose?.type === 'skeleton') {
            const charBox = { x: character.x, y: character.y, w: character.width, h: character.height };
            const modalBox = { x: 0, y: 0, w: MODAL_WIDTH, h: MODAL_HEIGHT };
            const newSkeletonData: SkeletonData = {};
            const defaultSkeleton = createInitialSkeleton(0, 0, 0, 0); // To get all keys
            for (const key in defaultSkeleton) {
                const charPoint = character.pose.data[key as keyof SkeletonData];
                if (charPoint) {
                    newSkeletonData[key as keyof SkeletonData] = {
                        x: modalBox.x + (charPoint.x - charBox.x) / charBox.w * modalBox.w,
                        y: modalBox.y + (charPoint.y - charBox.y) / charBox.h * modalBox.h,
                    };
                }
            }
            setSkeletonPose({ ...character.pose, data: newSkeletonData, preset: character.pose.preset || 'full' });
        }
    }, [character]);

    const handleSave = () => {
        let pose: Pose | null = null;
        if (activeTab === 'skeleton') {
            pose = skeletonPose;
        } else if (activeTab === 'upload' && uploadedImage) {
            pose = { type: 'image', href: uploadedImage };
        } else if (activeTab === 'draw' && drawingPoints.length > 0) {
            const normalizedPoints = drawingPoints.map(stroke => 
                stroke.map(p => ({
                    x: p.x / MODAL_WIDTH,
                    y: p.y / MODAL_HEIGHT,
                }))
            );
            pose = { type: 'drawing', points: normalizedPoints };
        }
        if (pose) {
            onSave(character.id, pose);
        }
    };
    
    const handleSkeletonMouseDown = (joint: string) => setDraggingJoint(joint);
    const handleSkeletonMouseMove = (e: React.MouseEvent) => {
        if (!draggingJoint) return;
        const svg = e.currentTarget as SVGSVGElement;
        const rect = svg.getBoundingClientRect();
        const x = Math.max(0, Math.min(e.clientX - rect.left, MODAL_WIDTH));
        const y = Math.max(0, Math.min(e.clientY - rect.top, MODAL_HEIGHT));
        setSkeletonPose(prev => ({ ...prev, data: { ...prev.data, [draggingJoint]: { x, y } } }));
    };
    const resetSkeleton = () => {
        setSkeletonPose(prev => ({...prev, data: createInitialSkeleton(0,0,MODAL_WIDTH, MODAL_HEIGHT)}));
    };
    
    const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (file) {
            const reader = new FileReader();
            reader.onload = (event) => setUploadedImage(event.target?.result as string);
            reader.readAsDataURL(file);
        }
    };

    const getDrawCoords = (e: React.MouseEvent | React.TouchEvent) => {
        const canvas = drawCanvasRef.current;
        if (!canvas) return null;
        const rect = canvas.getBoundingClientRect();
        const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
        const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
        return { x: clientX - rect.left, y: clientY - rect.top };
    }
    const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
        e.preventDefault();
        setIsDrawing(true);
        const pos = getDrawCoords(e);
        if (pos) {
            setDrawingPoints(prev => [...prev, [pos]]);
        }
    };
    const draw = (e: React.MouseEvent | React.TouchEvent) => {
        e.preventDefault();
        if (!isDrawing) return;
        const pos = getDrawCoords(e);
        if(pos) {
            setDrawingPoints(prev => {
                const newPoints = [...prev];
                newPoints[newPoints.length - 1].push(pos);
                return newPoints;
            });
        }
    };
    useEffect(() => {
        const canvas = drawCanvasRef.current;
        const ctx = canvas?.getContext('2d');
        if (ctx && canvas) {
            ctx.clearRect(0,0, canvas.width, canvas.height);
            ctx.strokeStyle = drawColor;
            ctx.lineWidth = drawSize;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            drawingPoints.forEach(stroke => {
                ctx.beginPath();
                stroke.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
                ctx.stroke();
            });
        }
    }, [drawingPoints, drawColor, drawSize]);

    const visibleJoints = useMemo(() => new Set(presetJoints[skeletonPose.preset || 'full']), [skeletonPose.preset]);
    const visibleConnections = useMemo(() => skeletonConnections.filter(([start, end]) => visibleJoints.has(start) && visibleJoints.has(end)), [visibleJoints]);

    const tabs = [
        { id: 'skeleton', name: t('skeleton'), icon: EditPoseIcon },
        { id: 'upload', name: t('uploadPose'), icon: UploadIcon },
        { id: 'draw', name: t('drawPose'), icon: BrushIcon },
    ];
    
    return (
        <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
            <div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[90vh]">
                 <div className="p-4 border-b border-gray-200 flex justify-between items-center">
                    <div>
                        <h3 className="text-lg font-bold text-gray-800">{t('editCharacterPose')}</h3>
                        <p className="text-sm text-gray-500">{t('editCharacterPoseDesc')}</p>
                    </div>
                    <button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100"><XIcon className="w-5 h-5 text-gray-600" /></button>
                </div>

                <div className="p-4 flex gap-4 flex-grow min-h-0">
                    <div className="flex-grow bg-gray-100 rounded-lg flex items-center justify-center relative aspect-[1/2]">
                        {activeTab === 'skeleton' && (
                             <svg width={MODAL_WIDTH} height={MODAL_HEIGHT} onMouseMove={handleSkeletonMouseMove} onMouseUp={() => setDraggingJoint(null)} onMouseLeave={() => setDraggingJoint(null)}>
                                {visibleConnections.map(([start, end]) => {
                                    if (!skeletonPose.data[start] || !skeletonPose.data[end]) return null;
                                    return <line key={`${start}-${end}`} x1={skeletonPose.data[start].x} y1={skeletonPose.data[start].y} x2={skeletonPose.data[end].x} y2={skeletonPose.data[end].y} stroke="#00BFFF" strokeWidth={4} strokeLinecap='round'/>
                                })}
                                {Object.entries(skeletonPose.data).filter(([key]) => visibleJoints.has(key)).map(([key, pos]) => {
                                    if (!pos) return null;
                                    return <circle key={key} cx={pos.x} cy={pos.y} r={8} fill={key === 'head' ? '#FF4500' : '#FF00FF'} stroke="white" strokeWidth={2} onMouseDown={() => handleSkeletonMouseDown(key)} className="cursor-grab active:cursor-grabbing" />
                                })}
                            </svg>
                        )}
                        {activeTab === 'upload' && (
                            <div className="w-full h-full flex items-center justify-center">
                                {uploadedImage ? <img src={uploadedImage} className="max-w-full max-h-full object-contain" /> : <p className="text-gray-500">{t('uploadPosePrompt')}</p>}
                            </div>
                        )}
                        {activeTab === 'draw' && (
                            <canvas ref={drawCanvasRef} width={MODAL_WIDTH} height={MODAL_HEIGHT} className="cursor-crosshair" onMouseDown={startDrawing} onMouseMove={draw} onMouseUp={() => setIsDrawing(false)} onMouseLeave={() => setIsDrawing(false)} onTouchStart={startDrawing} onTouchMove={draw} onTouchEnd={() => setIsDrawing(false)} />
                        )}
                    </div>
                    <div className="w-64 flex flex-col gap-4">
                        <div className="flex flex-col gap-1">
                            {tabs.map(tab => (
                                <button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`flex items-center gap-3 p-2 rounded-md text-sm font-semibold ${activeTab === tab.id ? 'bg-indigo-100 text-indigo-700' : 'text-gray-600 hover:bg-gray-100'}`}>
                                    <tab.icon className="w-5 h-5" />
                                    {tab.name}
                                </button>
                            ))}
                        </div>
                        <div className="border-t border-gray-200 pt-4 flex-grow flex flex-col gap-4">
                            {activeTab === 'skeleton' && (
                                <>
                                    <div>
                                        <label className="text-xs font-bold text-gray-500 uppercase">{t('posePreset')}</label>
                                        <select value={skeletonPose.preset} onChange={e => setSkeletonPose(p => ({...p, preset: e.target.value as any}))} className="w-full mt-1 p-2 bg-white border border-gray-300 rounded-md text-sm">
                                            <option value="full">{t('fullBody')}</option>
                                            <option value="upper">{t('upperBody')}</option>
                                            <option value="lower">{t('lowerBody')}</option>
                                            <option value="face">{t('face')}</option>
                                        </select>
                                    </div>
                                    <div>
                                        <label className="text-xs font-bold text-gray-500 uppercase">{t('poseComment')}</label>
                                        <input type="text" value={skeletonPose.comment} onChange={e => setSkeletonPose(p => ({...p, comment: e.target.value}))} placeholder={t('poseCommentPlaceholder')} className="w-full mt-1 p-2 bg-white border border-gray-300 rounded-md text-sm" />
                                    </div>
                                    <button onClick={resetSkeleton} className="w-full flex items-center justify-center gap-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 rounded-md"><RedoIcon className="w-4 h-4 transform scale-x-[-1]" />{t('resetSkeleton')}</button>
                                </>
                            )}
                             {activeTab === 'upload' && (
                                <>
                                 <input type="file" ref={uploadRef} onChange={handleUpload} accept="image/*" className="hidden" />
                                 <button onClick={() => uploadRef.current?.click()} className="w-full flex items-center justify-center gap-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 rounded-md"><UploadIcon className="w-5 h-5" />{t('uploadPose')}</button>
                                </>
                            )}
                             {activeTab === 'draw' && (
                                <>
                                    <div className="flex items-center gap-2">
                                        <label htmlFor="draw-color" className="text-sm font-medium text-gray-700">{t('brushColor')}</label>
                                        <input id="draw-color" type="color" value={drawColor} onChange={(e) => setDrawColor(e.target.value)} className="w-8 h-8 p-0 border-none rounded cursor-pointer bg-white" />
                                    </div>
                                    <div className="flex flex-col">
                                        <label htmlFor="draw-size" className="text-sm font-medium text-gray-700 mb-1">{t('brushSize')}</label>
                                        <div className="flex items-center gap-2">
                                            <input id="draw-size" type="range" min="1" max="50" value={drawSize} onChange={(e) => setDrawSize(Number(e.target.value))} className="w-full" />
                                            <span className="text-sm w-6 text-center font-semibold text-gray-600">{drawSize}</span>
                                        </div>
                                    </div>
                                    <button onClick={() => setDrawingPoints([])} className="w-full flex items-center justify-center gap-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 rounded-md"><TrashIcon className="w-5 h-5" />{t('clearDrawing')}</button>
                                </>
                            )}
                        </div>
                    </div>
                </div>

                <div className="p-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
                    <button onClick={onClose} className="bg-white border border-gray-300 text-gray-700 font-semibold py-2 px-5 rounded-lg hover:bg-gray-100 transition-colors text-sm">{t('cancel')}</button>
                    <button onClick={handleSave} className="bg-indigo-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-indigo-500 transition-colors text-sm">{t('savePose')}</button>
                </div>
            </div>
        </div>
    )
}
```

## /components/ResultDisplay.tsx

```tsx path="/components/ResultDisplay.tsx" 
import React, { useState, useCallback, useRef } from 'react';
import type { GeneratedContent, AnalysisResult, Character } from '../types';
import { useLocalization } from '../hooks/useLocalization';
import { RedoAltIcon, SparklesIcon, UploadIcon, XIcon, BrushIcon, ReturnIcon, WandIcon, CheckCircleIcon } from './icons';

interface ResultDisplayProps {
  isLoading: boolean;
  isColoring: boolean;
  generatedContent: GeneratedContent | null;
  error: string | null;
  isMonochromeResult: boolean;
  onColorize: () => void;
  onRegenerate: () => void;
  onEdit: (prompt: string, refImages: string[] | null) => void;
  onStartMasking: () => void;
  mask: string | null;
  onClearMask: () => void;
  onReturnToEditor: () => void;
  isAnalyzing: boolean;
  analysisResult: AnalysisResult | null;
  onAnalyze: () => void;
  onApplyCorrection: () => void;
  onClearAnalysis: () => void;
  characters: Character[];
}

const LoadingMessage = () => {
    const { t } = useLocalization();
    const messages = [
        t('loadingSketching'),
        t('loadingInking'),
        t('loadingBubbles'),
        t('loadingScreentones'),
        t('loadingFinalizing'),
    ];
    const [message, setMessage] = React.useState(messages[0]);

    React.useEffect(() => {
        let index = 0;
        const intervalId = setInterval(() => {
            index = (index + 1) % messages.length;
            setMessage(messages[index]);
        }, 2500);

        return () => clearInterval(intervalId);
    }, [messages]);

    return <p className="text-gray-500 mt-4">{message}</p>
};

export function ResultDisplay({ 
    isLoading, 
    isColoring, 
    generatedContent, 
    error, 
    isMonochromeResult, 
    onColorize,
    onRegenerate,
    onEdit,
    onStartMasking,
    mask,
    onClearMask,
    onReturnToEditor,
    isAnalyzing,
    analysisResult,
    onAnalyze,
    onApplyCorrection,
    onClearAnalysis,
    characters
}: ResultDisplayProps): React.ReactElement {
  const { t } = useLocalization();
  const [editPrompt, setEditPrompt] = useState('');
  const [editRefImages, setEditRefImages] = useState<string[]>([]);
  const [editRefCharacterIds, setEditRefCharacterIds] = useState<Set<string>>(new Set());
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleApplyEdits = () => {
    if (editPrompt) {
        const selectedCharSheets = characters
            .filter(c => editRefCharacterIds.has(c.id))
            .map(c => c.sheetImage);

        const allRefImages = [...editRefImages, ...selectedCharSheets];

        onEdit(editPrompt, allRefImages.length > 0 ? allRefImages : null);
        setEditPrompt('');
        setEditRefImages([]);
        setEditRefCharacterIds(new Set());
    }
  };

  const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    if (files) {
      const fileArray = Array.from(files);
      const remainingSlots = 8 - editRefImages.length;
      if (remainingSlots <= 0) return;

      const filesToProcess = fileArray.slice(0, remainingSlots);
      
      filesToProcess.forEach(file => {
          const reader = new FileReader();
          reader.onloadend = () => {
              setEditRefImages(prev => [...prev, reader.result as string]);
          };
          reader.readAsDataURL(file);
      });
    }
  }, [editRefImages]);
  
  const handleRemoveRefImage = (index: number) => {
      setEditRefImages(prev => prev.filter((_, i) => i !== index));
  }

  const toggleRefChar = (charId: string) => {
    setEditRefCharacterIds(prev => {
        const newSet = new Set(prev);
        if (newSet.has(charId)) {
            newSet.delete(charId);
        } else {
            newSet.add(charId);
        }
        return newSet;
    });
  };

  if (isLoading || isColoring || isAnalyzing) {
    return (
        <div className="bg-white rounded-xl h-full flex flex-col items-center justify-center p-6 text-center">
            <svg className="animate-spin h-10 w-10 text-indigo-600 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
            </svg>
            <p className="text-lg font-semibold mt-4">{isAnalyzing ? t('analyzing') : (isLoading && !isColoring ? t('editing') : '')}</p>
            {isColoring ? <p className="text-gray-500 mt-1">{t('coloringPage')}</p> : isAnalyzing ? null : <LoadingMessage />}
        </div>
    );
  }

  return (
    <div className="bg-white rounded-xl h-full flex flex-col">
      <div className="px-6 pt-6 pb-2 flex justify-between items-center">
        <h2 className="text-xl font-bold text-gray-800">{t('result')}</h2>
        <button 
          onClick={onReturnToEditor}
          className="flex items-center gap-2 text-sm font-semibold text-indigo-600 hover:text-indigo-800 hover:underline"
        >
          <ReturnIcon className="w-5 h-5" />
          {t('returnToEditor')}
        </button>
      </div>

      <div className="flex-grow bg-gray-50 rounded-b-xl flex flex-col p-4 gap-4 overflow-y-auto">
        {error ? (
            <div className="text-red-700 bg-red-100 p-4 rounded-lg border border-red-300 text-sm m-2">{error}</div>
        ) : generatedContent?.image ? (
            <>
                <div className="relative group">
                     <img src={generatedContent.image} alt="Generated manga page" className="w-full object-contain rounded-md shadow-lg border border-gray-200" />
                     {mask && (
                        <div className="absolute inset-0 bg-indigo-500/30 backdrop-blur-sm flex items-center justify-center rounded-md pointer-events-none">
                            <p className="text-white font-bold text-lg bg-black/50 px-4 py-2 rounded-lg">Mask Applied</p>
                        </div>
                     )}
                </div>

                <div className="flex flex-col gap-4 p-2">
                    <div className="grid grid-cols-2 gap-2">
                        {isMonochromeResult && (
                            <button onClick={onColorize} disabled={isColoring} className="col-span-2 w-full bg-teal-500 text-white font-bold py-2 px-4 rounded-lg hover:bg-teal-600 transition-colors flex items-center justify-center text-sm">
                                {t('colorizePage')}
                            </button>
                        )}
                         <button onClick={onRegenerate} className="w-full bg-gray-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-gray-500 transition-colors flex items-center justify-center gap-2 text-sm">
                            <RedoAltIcon className="w-4 h-4" /> {t('regenerate')}
                        </button>
                        <button onClick={onStartMasking} className="w-full bg-purple-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-purple-500 transition-colors flex items-center justify-center gap-2 text-sm">
                           <BrushIcon className="w-4 h-4" /> {t('editWithMask')}
                        </button>
                    </div>
                    
                    <button 
                        onClick={onAnalyze} 
                        className="w-full bg-blue-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-blue-500 transition-colors flex items-center justify-center gap-2 text-sm">
                        <WandIcon className="w-4 h-4" /> {t('analyzeResult')}
                    </button>

                    {analysisResult && (
                        <div className="border-t border-gray-200 pt-4 animate-fade-in">
                            <div className="flex justify-between items-center mb-2">
                                <h3 className="text-md font-semibold text-gray-700">{t('analysisReport')}</h3>
                                <button onClick={onClearAnalysis} className="p-1 rounded-full hover:bg-gray-200"><XIcon className="w-4 h-4 text-gray-500" /></button>
                            </div>
                            <div className="bg-gray-100 p-3 rounded-md border border-gray-200 text-sm">
                                <p className="text-gray-800">{analysisResult.analysis}</p>
                                {analysisResult.has_discrepancies ? (
                                    <button
                                        onClick={onApplyCorrection}
                                        className="w-full mt-3 bg-indigo-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-indigo-500 transition-colors flex items-center justify-center gap-2 text-sm"
                                    >
                                        <SparklesIcon className="w-5 h-5" /> {t('applyCorrection')}
                                    </button>
                                ) : (
                                    <div className="mt-3 flex items-center justify-center gap-2 text-green-600 font-semibold">
                                        <CheckCircleIcon className="w-5 h-5" />
                                        <span>{t('noCorrectionsNeeded')}</span>
                                    </div>
                                )}
                            </div>
                        </div>
                    )}

                    <div className="border-t border-gray-200 pt-4">
                        <h3 className="text-md font-semibold text-gray-700 mb-2">{t('editResult')}</h3>
                        {mask && (
                            <div className="mb-2 flex justify-between items-center bg-indigo-50 p-2 rounded-md border border-indigo-200">
                                <p className="text-xs font-semibold text-indigo-700">Mask is active for this edit.</p>
                                <button onClick={onClearMask} className="text-xs text-indigo-500 hover:underline font-bold">{t('clearMask')}</button>
                            </div>
                        )}
                        <textarea
                            value={editPrompt}
                            onChange={(e) => setEditPrompt(e.target.value)}
                            placeholder={t('editPromptPlaceholder')}
                            className="w-full h-20 bg-white border border-gray-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition resize-y"
                        />
                         <div className="mt-2">
                             <h4 className="text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">{t('uploadReference')}</h4>
                            <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" id="edit-ref-upload" multiple />
                            <div className="grid grid-cols-4 gap-2">
                                {editRefImages.map((img, index) => (
                                    <div key={index} className="relative group aspect-square">
                                        <img src={img} alt={`Ref ${index + 1}`} className="w-full h-full object-cover rounded-md border border-gray-200" />
                                        <button onClick={() => handleRemoveRefImage(index)} className="absolute top-0.5 right-0.5 bg-black/60 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100"><XIcon className="w-3 h-3" /></button>
                                    </div>
                                ))}
                                {editRefImages.length < 8 && (
                                    <label htmlFor="edit-ref-upload" className="flex flex-col items-center justify-center aspect-square border-2 border-dashed border-gray-300 text-gray-500 rounded-md cursor-pointer hover:border-indigo-500 hover:text-indigo-600">
                                        <UploadIcon className="w-5 h-5" />
                                        <span className="text-xs mt-1">{t('uploadReference')}</span>
                                        <span className="text-xs text-gray-400">({editRefImages.length}/8)</span>
                                    </label>
                                )}
                            </div>
                         </div>
                         <div className="mt-2">
                            <h4 className="text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">{t('characters')}</h4>
                            {characters.length > 0 ? (
                                <div className="grid grid-cols-4 gap-2">
                                    {characters.map(char => (
                                        <div key={char.id} onClick={() => toggleRefChar(char.id)} className="relative group aspect-square cursor-pointer">
                                            <img src={char.sheetImage} alt={char.name} className={`w-full h-full object-cover rounded-md border-2 ${editRefCharacterIds.has(char.id) ? 'border-indigo-500' : 'border-transparent'}`} />
                                             {editRefCharacterIds.has(char.id) && (
                                                <div className="absolute inset-0 bg-indigo-600/60 rounded-sm flex items-center justify-center">
                                                    <CheckCircleIcon className="w-6 h-6 text-white" />
                                                </div>
                                            )}
                                        </div>
                                    ))}
                                </div>
                            ) : (
                                <p className="text-xs text-gray-400 text-center bg-gray-100 p-2 rounded-md">{t('createCharacterPrompt')}</p>
                            )}
                         </div>

                        <button
                            onClick={handleApplyEdits}
                            disabled={!editPrompt}
                            className="w-full mt-3 bg-indigo-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-indigo-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2 text-sm"
                        >
                            <SparklesIcon className="w-5 h-5" /> {t('applyEdits')}
                        </button>
                    </div>
                </div>
            </>
        ) : null}
      </div>
    </div>
  );
}
```

## /components/StoryInput.tsx

```tsx path="/components/StoryInput.tsx" 
import React from 'react';
import { useLocalization } from '../hooks/useLocalization';
import { LightbulbIcon } from './icons';

interface StoryInputProps {
  story: string;
  onStoryChange: (story: string) => void;
  onSuggestStory: () => void;
}

export function StoryInput({ story, onStoryChange, onSuggestStory }: StoryInputProps): React.ReactElement {
  const { t } = useLocalization();

  return (
    <div className="bg-white rounded-xl p-5 border border-gray-200 shadow-sm">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-md font-semibold text-gray-700">{t('sceneNotes')}</h2>
        <button
          onClick={onSuggestStory}
          className="flex items-center gap-2 bg-yellow-400 text-yellow-900 font-bold py-1.5 px-3 rounded-lg hover:bg-yellow-500 transition-colors text-xs disabled:bg-gray-300"
        >
          <LightbulbIcon className="w-4 h-4" />
          {t('getAiSuggestions')}
        </button>
      </div>
      <textarea
        value={story}
        onChange={(e) => onStoryChange(e.target.value)}
        placeholder={t('sceneNotesPlaceholder')}
        className="w-full h-48 bg-gray-50 border border-gray-300 rounded-md p-3 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition resize-y"
        aria-label="Scene notes input"
      />
    </div>
  );
}
```

## /components/StorySuggestionModal.tsx

```tsx path="/components/StorySuggestionModal.tsx" 
import React, { useState } from 'react';
import { useLocalization } from '../hooks/useLocalization';
import { LightbulbIcon, XIcon } from './icons';
import type { StorySuggestion } from '../types';

interface StorySuggestionModalProps {
  onClose: () => void;
  onGenerate: (premise: string, shouldContinue: boolean) => void;
  isLoading: boolean;
  suggestion: StorySuggestion | null;
  onApply: (script: string) => void;
}

export function StorySuggestionModal({
  onClose,
  onGenerate,
  isLoading,
  suggestion,
  onApply,
}: StorySuggestionModalProps) {
  const { t } = useLocalization();
  const [premise, setPremise] = useState('');
  const [shouldContinue, setShouldContinue] = useState(true);

  const handleGenerate = () => {
    onGenerate(premise, shouldContinue);
  };

  const handleApply = () => {
    if (!suggestion) return;
    const formattedScript = suggestion.panels
      .map(p => {
        const dialogue = p.dialogue ? `\n${p.dialogue}` : '';
        return `Panel ${p.panel}: ${p.description}${dialogue}`;
      })
      .join('\n\n');
    onApply(formattedScript);
  };

  return (
    <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
      <div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl flex flex-col max-h-[90vh]">
        <div className="p-4 border-b border-gray-200 flex justify-between items-center">
          <div className="flex items-center gap-3">
            <LightbulbIcon className="w-6 h-6 text-yellow-500" />
            <div>
              <h3 className="text-lg font-bold text-gray-800">{t('storySuggestionTitle')}</h3>
              <p className="text-sm text-gray-500">{t('storySuggestionDescription')}</p>
            </div>
          </div>
          <button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100">
            <XIcon className="w-5 h-5 text-gray-600" />
          </button>
        </div>
        
        <div className="p-4 flex-grow overflow-y-auto grid grid-cols-2 gap-4">
          {/* Left: Input */}
          <div className="flex flex-col gap-4 pr-4 border-r border-gray-200">
            <div>
              <label htmlFor="story-idea" className="block text-sm font-semibold text-gray-600 mb-1">{t('storyIdea')}</label>
              <textarea
                id="story-idea"
                value={premise}
                onChange={(e) => setPremise(e.target.value)}
                placeholder={t('storyIdeaPlaceholder')}
                className="w-full h-32 bg-gray-50 border border-gray-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition resize-y"
              />
            </div>
            <div className="flex items-center gap-2">
              <input
                id="continue-story"
                type="checkbox"
                checked={shouldContinue}
                onChange={(e) => setShouldContinue(e.target.checked)}
                className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
              />
              <label htmlFor="continue-story" className="text-sm font-medium text-gray-700">{t('continueFromPrevious')}</label>
            </div>
            <button
              onClick={handleGenerate}
              disabled={isLoading}
              className="w-full bg-yellow-400 text-yellow-900 font-bold py-2.5 px-4 rounded-lg hover:bg-yellow-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
            >
              {isLoading ? t('storySuggesting') : t('generateSuggestion')}
            </button>
          </div>

          {/* Right: Output */}
          <div className="flex flex-col">
            <h4 className="text-sm font-semibold text-gray-600 mb-2">{t('aiSuggestion')}</h4>
            <div className="flex-grow bg-gray-50 rounded-md border p-3 overflow-y-auto">
              {isLoading ? (
                <div className="flex items-center justify-center h-full text-gray-500">
                  <svg className="animate-spin h-6 w-6 mr-3" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
                  <span>{t('storySuggesting')}</span>
                </div>
              ) : suggestion ? (
                <div className="space-y-4 text-sm">
                  {suggestion.panels.map((panel) => (
                    <div key={panel.panel}>
                      <p className="font-bold text-gray-800">{t('panel')} {panel.panel}</p>
                      <p className="text-gray-600 pl-2 border-l-2 border-gray-200 ml-1 mt-1">
                        <strong className="font-semibold">{t('description')}:</strong> {panel.description}
                      </p>
                      {panel.dialogue && (
                        <p className="text-gray-600 pl-2 border-l-2 border-gray-200 ml-1 mt-1">
                          <strong className="font-semibold">{t('dialogue')}:</strong> <em>{panel.dialogue}</em>
                        </p>
                      )}
                    </div>
                  ))}
                </div>
              ) : (
                <p className="text-gray-400 text-center text-xs py-10">{t('aiSuggestion')} will appear here.</p>
              )}
            </div>
          </div>
        </div>

        <div className="p-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
          <button onClick={onClose} className="bg-white border border-gray-300 text-gray-700 font-semibold py-2 px-5 rounded-lg hover:bg-gray-100 transition-colors text-sm">{t('cancel')}</button>
          <button
            onClick={handleApply}
            disabled={!suggestion}
            className="bg-indigo-600 text-white font-bold py-2 px-5 rounded-lg hover:bg-indigo-500 transition-colors text-sm disabled:bg-gray-400"
          >
            {t('applyToScript')}
          </button>
        </div>
      </div>
    </div>
  );
}
```

## /contexts/LocalizationContext.tsx

```tsx path="/contexts/LocalizationContext.tsx" 
import React, { createContext, useState, useMemo, useCallback } from 'react';
import { locales, Language, LocaleKeys } from '../i18n/locales';

interface LocalizationContextType {
  language: Language;
  setLanguage: (language: Language) => void;
  t: (key: LocaleKeys, replacements?: { [key: string]: string | number }) => string;
}

export const LocalizationContext = createContext<LocalizationContextType | undefined>(undefined);

export const LocalizationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [language, setLanguage] = useState<Language>('zh');

  const t = useCallback((key: LocaleKeys, replacements?: { [key: string]: string | number }): string => {
    let translation = locales[language][key] || locales['en'][key] || key;
    if (replacements) {
        for (const rKey in replacements) {
            translation = translation.replace(`{${rKey}}`, String(replacements[rKey]));
        }
    }
    return translation;
  }, [language]);

  const value = useMemo(() => ({
    language,
    setLanguage,
    t
  }), [language, t]);

  return (
    <LocalizationContext.Provider value={value}>
      {children}
    </LocalizationContext.Provider>
  );
};
```

## /hooks/useLocalization.ts

```ts path="/hooks/useLocalization.ts" 
import { useContext } from 'react';
import { LocalizationContext } from '../contexts/LocalizationContext';

export const useLocalization = () => {
  const context = useContext(LocalizationContext);
  if (context === undefined) {
    throw new Error('useLocalization must be used within a LocalizationProvider');
  }
  return context;
};

```

## /index.tsx

```tsx path="/index.tsx" 
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { LocalizationProvider } from './contexts/LocalizationContext';

const rootElement = document.getElementById('root');
if (!rootElement) {
  throw new Error("Could not find root element to mount to");
}

const root = ReactDOM.createRoot(rootElement);
root.render(
  <React.StrictMode>
    <LocalizationProvider>
        <App />
    </LocalizationProvider>
  </React.StrictMode>
);

```

## /og.webp

Binary file available at https://raw.githubusercontent.com/morsoli/aimangastudio/refs/heads/main/og.webp


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!