"use client"; import React, { useState, useEffect, useMemo, useRef } from 'react'; import { DEFAULT_SCRIPT, parseScript, Statement } from '@/lib/vn-parser'; import { VNOutline } from '@/components/VNOutline'; import { VNEditor } from '@/components/VNEditor'; import { VNPreview } from '@/components/VNPreview'; import { INITIAL_SPRITE_BANK, Sprite } from '@/lib/sprite-bank'; const STORAGE_KEY_SCRIPT = 'vn_editor_script'; const STORAGE_KEY_FILENAME = 'vn_editor_filename'; export default function VisualNovelEditorPage() { const [scriptText, setScriptText] = useState(DEFAULT_SCRIPT); const [fileName, setFileName] = useState("scene_01.vns"); const [statements, setStatements] = useState([]); const [spriteBank, setSpriteBank] = useState>(INITIAL_SPRITE_BANK); const [currentSayIndex, setCurrentSayIndex] = useState(0); const [isParsing, setIsParsing] = useState(false); const editorRef = useRef(null); // Load from LocalStorage useEffect(() => { const savedScript = localStorage.getItem(STORAGE_KEY_SCRIPT); const savedFileName = localStorage.getItem(STORAGE_KEY_FILENAME); if (savedScript) setScriptText(savedScript); if (savedFileName) setFileName(savedFileName); }, []); // Save to LocalStorage useEffect(() => { localStorage.setItem(STORAGE_KEY_SCRIPT, scriptText); localStorage.setItem(STORAGE_KEY_FILENAME, fileName); }, [scriptText, fileName]); const updateSpriteBank = (newBank: Record) => { setSpriteBank(newBank); }; const addSpriteToScript = (char: string, id: string, url: string) => { // Add to the top of the script or after existing defines setScriptText(prev => `define_sprite ${char} ${id} "${url}"\n${prev}`); }; // Parse Debounce useEffect(() => { const timer = setTimeout(() => { setIsParsing(true); const parsed = parseScript(scriptText); setStatements(parsed); // Sync Sprite Bank from define_sprite commands in script const newScriptBank: Record = {}; parsed.forEach(s => { if (s.type === 'comment' && s.text?.startsWith('DEF_SPRITE')) { const parts = s.text.split(' '); const char = parts[1]; const id = parts[2]; const url = parts[3]?.replace(/^"(.*)"$/, '$1'); if (char && id && url) { if (!newScriptBank[char]) newScriptBank[char] = []; if (!newScriptBank[char].some(sp => sp.id === id)) { newScriptBank[char].push({ id, name: id, url }); } } } }); if (Object.keys(newScriptBank).length > 0) { setSpriteBank(prev => { const merged = { ...prev }; Object.entries(newScriptBank).forEach(([char, sprites]) => { if (!merged[char]) { merged[char] = sprites; } else { const existingIds = new Set(sprites.map(s => s.id)); merged[char] = [ ...merged[char].filter(s => !existingIds.has(s.id)), ...sprites ]; } }); return merged; }); } setIsParsing(false); }, 300); return () => clearTimeout(timer); }, [scriptText]); const previewStatements = useMemo(() => statements.filter(s => s.type === 'say' || s.type === 'choice'), [statements] ); const currentStatement = previewStatements[currentSayIndex] || null; const handleChoiceSelect = (jumpLabel: string) => { const labelIdx = statements.findIndex(s => s.type === 'label' && s.label === jumpLabel); if (labelIdx !== -1) { // Find the first 'previewable' statement at or after the label const nextPreviewIdx = previewStatements.findIndex(s => s.lineNumber >= statements[labelIdx].lineNumber); if (nextPreviewIdx !== -1) { setCurrentSayIndex(nextPreviewIdx); } } }; const handleSave = () => { const blob = new Blob([scriptText], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); }; const handleLoad = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { if (typeof e.target?.result === 'string') { setScriptText(e.target.result); setFileName(file.name); } }; reader.readAsText(file); }; const scrollToLine = (line: number) => { if (!editorRef.current) return; const lines = scriptText.split('\n'); let pos = 0; for (let i = 0; i < line - 1; i++) { pos += lines[i].length + 1; } editorRef.current.focus(); editorRef.current.setSelectionRange(pos, pos + (lines[line-1]?.length || 0)); const lineHeight = 24; editorRef.current.scrollTop = (line - 5) * lineHeight; const previewIdx = previewStatements.findIndex(s => s.lineNumber === line); if (previewIdx !== -1) { setCurrentSayIndex(previewIdx); } }; return (
{/* Header */}
setFileName(e.target.value)} className="bg-transparent border-b border-gray-700 focus:border-cyan-400 outline-none px-2 py-1 text-sm font-mono text-cyan-400" />
setCurrentSayIndex(prev => Math.max(0, prev - 1))} onNext={() => setCurrentSayIndex(prev => Math.min(previewStatements.length - 1, prev + 1))} statements={statements} spriteBank={spriteBank} onChoiceSelect={handleChoiceSelect} />
); }