Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 48 additions & 17 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
import ImageMapComposer from './components/ImageMapComposer';
import ScreenLayoutComposer from './components/ScreenLayoutComposer';
import MarkdownPreviewView from './components/MarkdownPreviewView';
import PunchlistManager from './components/PunchlistManager';
import DiagnosticsPanel from './components/DiagnosticsPanel';
import { useDiagnostics, migratePunchlistToTasks } from './hooks/useDiagnostics';
import TabContextMenu from './components/TabContextMenu';
import Sash from './components/Sash';
import StatusBar from './components/StatusBar';
Expand All @@ -35,21 +36,21 @@
import { useRenpyAnalysis, performRenpyAnalysis, performRouteAnalysis } from './hooks/useRenpyAnalysis';
import { useHistory } from './hooks/useHistory';
import type {
Block, BlockGroup, Link, Position, FileSystemTreeNode, EditorTab,

Check warning on line 39 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'Link' is defined but never used. Allowed unused vars must match /^_/u
ToastMessage, IdeSettings, Theme, ProjectImage, RenpyAudio,

Check warning on line 40 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'IdeSettings' is defined but never used. Allowed unused vars must match /^_/u
ClipboardState, ImageMetadata, AudioMetadata, LabelNode, Character,

Check warning on line 41 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'LabelNode' is defined but never used. Allowed unused vars must match /^_/u
AppSettings, ProjectSettings, StickyNote, SceneComposition, SceneSprite, ImageMapComposition, ScreenLayoutComposition, PunchlistMetadata, MouseGestureSettings,
AppSettings, ProjectSettings, StickyNote, SceneComposition, SceneSprite, ImageMapComposition, ScreenLayoutComposition, PunchlistMetadata, DiagnosticsTask, MouseGestureSettings,

Check warning on line 42 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'MouseGestureSettings' is defined but never used. Allowed unused vars must match /^_/u
ProjectLoadResult, ScannedImageAsset, ScannedAudioAsset, SerializedSprite, SerializedSceneComposition, UserSnippet

Check warning on line 43 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'ScannedAudioAsset' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 43 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'ScannedImageAsset' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 43 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'ProjectLoadResult' is defined but never used. Allowed unused vars must match /^_/u
} from './types';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import packageJson from './package.json';

Check warning on line 46 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'packageJson' is defined but never used. Allowed unused vars must match /^_/u

// --- Versioning ---
const APP_VERSION = process.env.APP_VERSION || '0.4.0';
const BUILD_NUMBER = process.env.BUILD_NUMBER || 'dev';

// --- Utility: ArrayBuffer to Base64 (Browser Compatible) ---
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {

Check warning on line 53 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'arrayBufferToBase64' is assigned a value but never used. Allowed unused vars must match /^_/u
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
Expand Down Expand Up @@ -338,7 +339,7 @@
}
}, [projectRootPath]);

const [directoryHandle, setDirectoryHandle] = useState<FileSystemDirectoryHandle | null>(null);

Check warning on line 342 in App.tsx

View workflow job for this annotation

GitHub Actions / Test & Lint

'setDirectoryHandle' is assigned a value but never used. Allowed unused vars must match /^_/u
const [fileSystemTree, setFileSystemTree] = useState<FileSystemTreeNode | null>(null);

// Use standard useState for Maps to avoid Immer proxy issues with native Maps
Expand Down Expand Up @@ -383,8 +384,10 @@
// Screen Layout Composer State
const [screenLayoutCompositions, setScreenLayoutCompositions] = useImmer<Record<string, ScreenLayoutComposition>>({});

// Punchlist State
// Punchlist State (kept for migration — not written on save)
const [punchlistMetadata, setPunchlistMetadata] = useImmer<Record<string, PunchlistMetadata>>({});
// Diagnostics Tasks State
const [diagnosticsTasks, setDiagnosticsTasks] = useImmer<DiagnosticsTask[]>([]);

const [dirtyBlockIds, setDirtyBlockIds] = useState<Set<string>>(new Set());
const [dirtyEditors, setDirtyEditors] = useState<Set<string>>(new Set()); // Blocks modified in editor but not synced to block state yet
Expand Down Expand Up @@ -458,6 +461,7 @@

// --- Analysis ---
const analysisResult = useRenpyAnalysis(blocks, 0); // 0 is a trigger for force re-analysis if needed
const diagnosticsResult = useDiagnostics(blocks, analysisResult, images, imageMetadata, audios, audioMetadata);

// --- Refs ---
const editorInstances = useRef<Map<string, monaco.editor.IStandaloneCodeEditor>>(new Map());
Expand Down Expand Up @@ -1226,7 +1230,7 @@
}, [blocks, analysisResult, handleTidyUp]);

// --- Tab Management Helpers ---
const handleOpenStaticTab = useCallback((type: 'canvas' | 'route-canvas' | 'punchlist' | 'ai-generator' | 'stats') => {
const handleOpenStaticTab = useCallback((type: 'canvas' | 'route-canvas' | 'diagnostics' | 'ai-generator' | 'stats') => {
const id = type;
// If already open in primary, activate it there
if (openTabs.find(t => t.id === id)) {
Expand Down Expand Up @@ -1355,6 +1359,14 @@
setStickyNotes(projectData.settings.stickyNotes || []);
setCharacterProfiles(projectData.settings.characterProfiles || {});
setPunchlistMetadata(projectData.settings.punchlistMetadata || {});
// Diagnostics tasks — migrate from old punchlist metadata if needed
if (projectData.settings.diagnosticsTasks) {
setDiagnosticsTasks(projectData.settings.diagnosticsTasks);
} else if (projectData.settings.punchlistMetadata) {
setDiagnosticsTasks(migratePunchlistToTasks(projectData.settings.punchlistMetadata));
} else {
setDiagnosticsTasks([]);
}

// Load Scene Compositions
// Helper to link saved paths back to loaded image objects
Expand Down Expand Up @@ -1521,7 +1533,7 @@
if (tab.type === 'markdown' && tab.filePath) {
return true; // File existence checked on tab render
}
return tab.type === 'canvas' || tab.type === 'route-canvas' || tab.type === 'punchlist' || tab.type === 'ai-generator' || tab.type === 'stats';
return tab.type === 'canvas' || tab.type === 'route-canvas' || tab.type === 'punchlist' || tab.type === 'diagnostics' || tab.type === 'ai-generator' || tab.type === 'stats';
});

const rehydratedTabs = validTabs.map(tab => {
Expand All @@ -1531,6 +1543,10 @@
return { ...tab, id: matchingBlock.id, blockId: matchingBlock.id };
}
}
// Migrate old punchlist tab to diagnostics
if (tab.type === 'punchlist' || tab.id === 'punchlist') {
return { ...tab, type: 'diagnostics' as const, id: 'diagnostics' };
}
// Migrate old single scene tab
if (tab.type === 'scene-composer' && !tab.sceneId) {
return { ...tab, sceneId: 'scene-default' };
Expand All @@ -1552,7 +1568,7 @@
if (tab.type === 'audio' && tab.filePath) return audioMap.has(tab.filePath);
if (tab.type === 'character' && tab.characterTag) return tempAnalysis.characters.has(tab.characterTag);
if (tab.type === 'markdown' && tab.filePath) return true;
return tab.type === 'canvas' || tab.type === 'route-canvas' || tab.type === 'punchlist' || tab.type === 'ai-generator' || tab.type === 'stats' || tab.type === 'scene-composer';
return tab.type === 'canvas' || tab.type === 'route-canvas' || tab.type === 'punchlist' || tab.type === 'diagnostics' || tab.type === 'ai-generator' || tab.type === 'stats' || tab.type === 'scene-composer';
});
setSplitLayout(validSecondary.length > 0 ? savedSplitLayout : 'none');
setSplitPrimarySize(projectData.settings.splitPrimarySize ?? 600);
Expand Down Expand Up @@ -1933,6 +1949,7 @@
stickyNotes: Array.from(stickyNotes),
characterProfiles,
punchlistMetadata,
diagnosticsTasks,
sceneCompositions: serializableScenes as unknown as Record<string, SceneComposition>,
sceneNames,
imagemapCompositions: serializableImagemaps,
Expand All @@ -1947,7 +1964,7 @@
console.error("Failed to save IDE settings:", e);
addToast('Failed to save workspace settings', 'error');
}
}, [projectRootPath, projectSettings, openTabs, activeTabId, splitLayout, splitPrimarySize, secondaryOpenTabs, secondaryActiveTabId, stickyNotes, characterProfiles, addToast, sceneCompositions, sceneNames, imagemapCompositions, screenLayoutCompositions, imageScanDirectories, audioScanDirectories, punchlistMetadata]);
}, [projectRootPath, projectSettings, openTabs, activeTabId, splitLayout, splitPrimarySize, secondaryOpenTabs, secondaryActiveTabId, stickyNotes, characterProfiles, addToast, sceneCompositions, sceneNames, imagemapCompositions, screenLayoutCompositions, imageScanDirectories, audioScanDirectories, punchlistMetadata, diagnosticsTasks]);


const handleSaveAll = useCallback(async () => {
Expand Down Expand Up @@ -2796,7 +2813,7 @@
if (data.command === 'save-all') handleSaveAll();
if (data.command === 'run-project' && projectRootPath) window.electronAPI?.runGame(appSettings.renpyPath, projectRootPath);
if (data.command === 'stop-project') window.electronAPI?.stopGame();
if (data.command === 'open-static-tab' && data.type) handleOpenStaticTab(data.type as 'canvas' | 'route-canvas' | 'punchlist' | 'ai-generator');
if (data.command === 'open-static-tab' && data.type) handleOpenStaticTab(data.type as 'canvas' | 'route-canvas' | 'diagnostics' | 'ai-generator');
if (data.command === 'toggle-search') handleToggleSearch();
if (data.command === 'open-settings') setSettingsModalOpen(true);
if (data.command === 'open-shortcuts') setShortcutsModalOpen(true);
Expand Down Expand Up @@ -2902,7 +2919,7 @@
const getTabLabel = (tab: EditorTab): React.ReactNode => {
if (tab.id === 'canvas') return 'Story Canvas';
if (tab.id === 'route-canvas') return 'Route Canvas';
if (tab.id === 'punchlist') return 'Punchlist';
if (tab.id === 'diagnostics' || tab.id === 'punchlist') return 'Diagnostics';
if (tab.id === 'stats') return 'Stats';
if (tab.type === 'ai-generator') return 'AI Generator';
if (tab.type === 'scene-composer') return sceneNames[tab.sceneId!] || 'Scene';
Expand Down Expand Up @@ -2939,12 +2956,12 @@
mouseGestures={appSettings.mouseGestures}
/>;
}
if (tab.type === 'punchlist') {
return <PunchlistManager
blocks={blocks} stickyNotes={stickyNotes} analysisResult={analysisResult}
projectImages={images} imageMetadata={imageMetadata} projectAudios={audios} audioMetadata={audioMetadata}
punchlistMetadata={punchlistMetadata}
onUpdateMetadata={(id, data) => { setPunchlistMetadata(draft => { if (data === undefined) { delete draft[id]; } else { draft[id] = { ...draft[id], ...data }; } }); setHasUnsavedSettings(true); }}
if (tab.type === 'diagnostics' || tab.type === 'punchlist') {
return <DiagnosticsPanel
diagnostics={diagnosticsResult}
blocks={blocks} stickyNotes={stickyNotes}
tasks={diagnosticsTasks}
onUpdateTasks={(updated) => { setDiagnosticsTasks(updated); setHasUnsavedSettings(true); }}
onOpenBlock={handleOpenEditor} onHighlightBlock={(id) => handleCenterOnBlock(id)}
/>;
}
Expand All @@ -2955,7 +2972,15 @@
/>;
}
if (tab.id === 'stats') {
return <StatsView blocks={blocks} analysisResult={analysisResult} routeAnalysisResult={routeAnalysisResult} />;
return <StatsView
blocks={blocks}
analysisResult={analysisResult}
routeAnalysisResult={routeAnalysisResult}
imageCount={images.size}
audioCount={audios.size}
diagnosticsErrorCount={diagnosticsResult.errorCount}
onOpenDiagnostics={() => handleOpenStaticTab('diagnostics')}
/>;
}
if (tab.type === 'editor' && tab.blockId) {
const block = blocks.find(b => b.id === tab.blockId);
Expand Down Expand Up @@ -3080,6 +3105,11 @@
onContextMenu={(e) => handleTabContextMenu(e, tab.id, paneId)}
>
<span className="truncate flex-grow">{getTabLabel(tab)}</span>
{(tab.id === 'diagnostics' || tab.id === 'punchlist') && diagnosticsResult.errorCount > 0 && (
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded-full min-w-[18px] text-center flex-none">
{diagnosticsResult.errorCount}
</span>
)}
{tab.id !== 'canvas' && (
<button onClick={(e) => handleCloseTab(tab.id, paneId, e)} aria-label="Close tab" className="ml-2 opacity-0 group-hover:opacity-100 hover:text-red-500 rounded-full p-0.5">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
Expand Down Expand Up @@ -3161,7 +3191,8 @@
requestOpenFolder={handleOpenProjectFolder}
handleSave={handleSaveAll}
onOpenSettings={() => setSettingsModalOpen(true)}
onOpenStaticTab={handleOpenStaticTab as (type: 'canvas' | 'route-canvas' | 'stats') => void}
onOpenStaticTab={handleOpenStaticTab as (type: 'canvas' | 'route-canvas' | 'stats' | 'diagnostics') => void}
diagnosticsErrorCount={diagnosticsResult.errorCount}
onAddStickyNote={() => addStickyNote()}
isGameRunning={isGameRunning}
onRunGame={() => window.electronAPI?.runGame(appSettings.renpyPath, projectRootPath!)}
Expand Down
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Key rules: `react-hooks/rules-of-hooks` (error), `react-hooks/exhaustive-deps` (
- **Character, Variable, ImageAsset, AudioAsset, Screen, Scene**: Story element types
- **UserSnippet**: User-defined code snippet (id, title, prefix, description, code, optional monacoBody for placeholder support)
- **ProjectLoadResult, ScanDirectoryResult**: Typed IPC return shapes (replacing prior `any` usage)
- **SerializedSprite, SerializedSceneComposition**: JSON-safe versions of scene composer types
- **SerializedSprite, SerializedSceneComposition**: JSON-safe versions of scene composer types. `SceneComposition` carries an optional `resolution?: { width: number; height: number }` field (defaults to 1920×1080 when absent) persisted in `project.ide.json`.
- **ImageMapComposition**: Container for a clickable imagemap — ground image, optional hover overlay, and an array of hotspots. Persisted in `ProjectSettings.imagemapCompositions` (keyed by id) and saved to `project.ide.json`.
- **ImageMapHotspot**: A single clickable region with `x`, `y`, `width`, `height`, an `ImageMapActionType` (`'jump' | 'call'`), and a target label
- **SerializedImageMapComposition**: JSON-safe version of an imagemap composition
Expand Down Expand Up @@ -131,6 +131,7 @@ API keys are stored encrypted via Electron's `safeStorage` at `userData/api-keys
- **UI rendering**: Functional components with hooks only, no class components
- **Modals/overlays**: Rendered via `createPortal()`; all modals use `useModalAccessibility` hook for focus trap, Escape key close, and focus restore
- **Styling**: Tailwind CSS utility classes; dark mode via `class` strategy
- **Copy-to-clipboard**: Always use `components/CopyButton.tsx`. Props: `text` (string to copy), `label` (default `"Copy to Clipboard"`), `size` (`'xs'` for code-preview headers / list rows, `'sm'` default, `'md'` for primary action buttons). Idle state: clipboard icon + label. After click: green bg, checkmark, "Copied!" for 2 s. Never write per-component clipboard state or `alert()` feedback.
- **Path alias**: `@/*` maps to project root in imports (tsconfig)
- **Block = file**: Each `.rpy` file maps 1:1 to a Block on the canvas; the first label becomes the block title
- **Accessibility**: Icon-only buttons must have `aria-label`; modals must have `role="dialog"`, `aria-modal`, and `aria-labelledby`
Expand Down Expand Up @@ -181,6 +182,15 @@ Users can create custom code snippets (persisted in `AppSettings.userSnippets`):

The app integrates AI APIs (Google Gemini via `@google/genai`, with optional OpenAI and Anthropic support via dynamic imports) for generating story content. API keys are encrypted at rest using Electron's `safeStorage`. The generator UI lives in `components/AIGenerator.tsx`.

## Scene Composer

`components/SceneComposer.tsx` provides a visual layout editor for positioning backgrounds and sprites:
- Drag-and-drop images from the Image Assets panel onto the stage; drag sprites to reposition
- Supports per-sprite zoom, flip, rotate, alpha, and blur controls; layer reordering via drag in the layer list
- **Configurable canvas resolution**: toolbar dropdown offers presets (1920×1080, 1280×720, 1024×768, 800×600) plus a "Custom…" option revealing W×H number inputs. Resolution persists in `SceneComposition.resolution` and is saved to `project.ide.json`. Defaults to 1920×1080 when absent (backwards compatible).
- Generates Ren'Py `scene`/`show` code in the Code Preview panel; exports a composited PNG via canvas
- Keyboard: Delete/Backspace removes selected sprite; Arrow keys nudge (Shift = 5× step); Escape clears selection

## ImageMap Composer

`components/ImageMapComposer.tsx` provides a visual editor for creating Ren'Py `imagebutton`/`imagemap` screens with clickable hotspot regions:
Expand Down
Loading
Loading