mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:10:45 +00:00
feat: SWF editor enhancements
This commit is contained in:
@@ -299,7 +299,7 @@ fn import_theme(app: AppHandle) -> Result<String, String> {
|
||||
#[tauri::command]
|
||||
fn pick_folder() -> Result<String, String> {
|
||||
let folder = rfd::FileDialog::new()
|
||||
.set_title("Select Custom TU Folder")
|
||||
.set_title("Select Export Folder")
|
||||
.pick_folder();
|
||||
|
||||
if let Some(path) = folder {
|
||||
@@ -309,6 +309,44 @@ fn pick_folder() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pick_file(title: String, filters: Vec<String>) -> Result<String, String> {
|
||||
let mut dialog = rfd::FileDialog::new().set_title(&title);
|
||||
if !filters.is_empty() {
|
||||
let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter("Files", &filters_ref);
|
||||
}
|
||||
if let Some(path) = dialog.pick_file() {
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
} else {
|
||||
Err("CANCELED".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_file_dialog(title: String, filename: String, filters: Vec<String>) -> Result<String, String> {
|
||||
let mut dialog = rfd::FileDialog::new().set_title(&title).set_file_name(&filename);
|
||||
if !filters.is_empty() {
|
||||
let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter("Files", &filters_ref);
|
||||
}
|
||||
if let Some(path) = dialog.save_file() {
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
} else {
|
||||
Err("CANCELED".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_binary_file(path: String, data: Vec<u8>) -> Result<(), String> {
|
||||
fs::write(path, data).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_binary_file(path: String) -> Result<Vec<u8>, String> {
|
||||
fs::read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_available_runners(app: AppHandle) -> Vec<Runner> {
|
||||
let mut runners = Vec::new();
|
||||
@@ -1686,7 +1724,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo])
|
||||
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { ArcService } from "../../services/ArcService";
|
||||
import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
export const ArcEditorView: React.FC = () => {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [arc, setArc] = useState<ArcFile | null>(null);
|
||||
const [openedPath, setOpenedPath] = useState<string | null>(null);
|
||||
const [loc, setLoc] = useState<LocFile | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"arc" | "loc">("arc");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -18,9 +21,9 @@ export const ArcEditorView: React.FC = () => {
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [isLocEditModalOpen, setIsLocEditModalOpen] = useState<{ langIdx: number, strIdx: number, isNew: boolean } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const injectInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!arc) return [];
|
||||
return arc.entries.map((e, i) => ({ ...e, originalIdx: i }))
|
||||
@@ -43,15 +46,17 @@ export const ArcEditorView: React.FC = () => {
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const handleFileLoad = async () => {
|
||||
try {
|
||||
const parsed = await ArcService.readARC(buffer);
|
||||
parsed.name = file.name;
|
||||
const path = await TauriService.pickFile("Open ARC", ["arc"]);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
const bytes = await TauriService.readBinaryFile(path);
|
||||
const parsed = await ArcService.readARC(bytes.buffer as ArrayBuffer);
|
||||
parsed.name = path.split(/[\/\\]/).pop() || "archive.arc";
|
||||
setArc(parsed);
|
||||
setOpenedPath(path);
|
||||
|
||||
const locEntry = parsed.entries.find(entry => entry.filename.toLowerCase() === "languages.loc");
|
||||
if (locEntry) {
|
||||
try {
|
||||
@@ -65,37 +70,48 @@ export const ArcEditorView: React.FC = () => {
|
||||
setLoc(null);
|
||||
}
|
||||
setSelectedEntryIdx(null);
|
||||
showNotification(`Loaded ${file.name}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse ARC", err);
|
||||
showNotification("Failed to parse ARC", "error");
|
||||
showNotification(`Loaded ${parsed.name}`);
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") {
|
||||
console.error("Failed to parse ARC", err);
|
||||
showNotification("Failed to parse ARC", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveArc = () => {
|
||||
const handleSaveArc = async () => {
|
||||
if (!arc) return;
|
||||
playPressSound();
|
||||
const buffer = ArcService.serializeARC(arc);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = arc.name || "archive.arc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("ARC Saved Successfully");
|
||||
const data = new Uint8Array(buffer);
|
||||
|
||||
try {
|
||||
let targetPath = openedPath;
|
||||
if (!targetPath) {
|
||||
targetPath = await TauriService.saveFileDialog("Save ARC", arc.name || "archive.arc", ["arc"]);
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
await TauriService.writeBinaryFile(targetPath, data);
|
||||
setOpenedPath(targetPath);
|
||||
showNotification("ARC Saved Successfully");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Save failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractEntry = (entry: ArcEntry) => {
|
||||
playPressSound();
|
||||
const blob = new Blob([entry.data as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = entry.filename.split("/").pop() || "asset";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification(`Extracted: ${entry.filename}`);
|
||||
const handleExtractEntry = async (entry: ArcEntry) => {
|
||||
try {
|
||||
const fileName = entry.filename.split("/").pop() || "asset";
|
||||
const path = await TauriService.saveFileDialog("Export Asset", fileName, []);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
await TauriService.writeBinaryFile(path, entry.data);
|
||||
showNotification(`Extracted: ${entry.filename}`);
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Extraction failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = (idx: number) => {
|
||||
@@ -195,34 +211,140 @@ export const ArcEditorView: React.FC = () => {
|
||||
showNotification("String Deleted");
|
||||
};
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
const root: any = { name: "<root>", children: {}, isFolder: true };
|
||||
filteredEntries.forEach((entry) => {
|
||||
const parts = entry.filename.split(/\//);
|
||||
let current = root;
|
||||
parts.forEach((part, i) => {
|
||||
const isLast = i === parts.length - 1;
|
||||
if (!current.children[part]) {
|
||||
current.children[part] = isLast
|
||||
? { ...entry, isFolder: false }
|
||||
: { name: part, children: {}, isFolder: true };
|
||||
}
|
||||
current = current.children[part];
|
||||
});
|
||||
});
|
||||
return root;
|
||||
}, [filteredEntries]);
|
||||
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set(["<root>"]));
|
||||
const toggleNode = (path: string) => {
|
||||
const newExpanded = new Set(expandedNodes);
|
||||
if (newExpanded.has(path)) newExpanded.delete(path);
|
||||
else newExpanded.add(path);
|
||||
setExpandedNodes(newExpanded);
|
||||
};
|
||||
|
||||
const renderTree = (node: any, path: string = "") => {
|
||||
const nodePath = path ? `${path}/${node.name}` : node.name;
|
||||
const isExpanded = expandedNodes.has(nodePath);
|
||||
|
||||
if (!node.isFolder) {
|
||||
const isSelected = selectedEntryIdx === node.originalIdx;
|
||||
return (
|
||||
<div
|
||||
key={node.originalIdx}
|
||||
onClick={() => { playPressSound(); setSelectedEntryIdx(node.originalIdx); }}
|
||||
className={`group flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors ${isSelected ? "bg-[#FFFF55]/20 text-[#FFFF55]" : "hover:bg-white/5 text-white/80"}`}
|
||||
>
|
||||
<img src="/images/Download_Icon.png" className="w-3 h-3 opacity-40" style={{ imageRendering: "pixelated" }} />
|
||||
<span className="truncate text-sm font-medium tracking-tight">
|
||||
{node.filename.split("/").pop()}
|
||||
</span>
|
||||
{node.isCompressed && (
|
||||
<span className="ml-auto text-[8px] opacity-40 border border-white/20 px-1">ZLIB</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={nodePath} className="flex flex-col">
|
||||
<div
|
||||
onClick={() => { playPressSound(); toggleNode(nodePath); }}
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-white/5 text-white/50 transition-colors group"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isExpanded ? 90 : 0 }}
|
||||
className="text-[10px]"
|
||||
>
|
||||
▶
|
||||
</motion.span>
|
||||
<img src="/images/tools/arc.png" className="w-4 h-4 opacity-40 grayscale" style={{ imageRendering: "pixelated" }} />
|
||||
<span className="text-xs uppercase tracking-widest font-bold group-hover:text-white/80 transition-colors">
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="ml-4 border-l border-white/10 overflow-hidden"
|
||||
>
|
||||
{Object.values(node.children).map((child: any) => renderTree(child, nodePath))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (!arc || arc.entries.length === 0) return;
|
||||
try {
|
||||
const baseFolder = await TauriService.pickFolder();
|
||||
if (!baseFolder) return;
|
||||
playPressSound();
|
||||
showNotification("Exporting all archive entries...");
|
||||
|
||||
for (const entry of arc.entries) {
|
||||
const fileName = entry.filename.replace(/\//g, "_");
|
||||
await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data);
|
||||
}
|
||||
showNotification("All Entries Exported");
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedEntry = selectedEntryIdx !== null ? arc?.entries[selectedEntryIdx] : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
|
||||
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
|
||||
className="flex flex-col items-center w-full max-w-7xl h-[85vh] outline-none"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".arc" />
|
||||
<input type="file" ref={injectInputRef} onChange={handleAddEntry} className="hidden" />
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplaceEntry} className="hidden" />
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">ARC Editor</h2>
|
||||
{arc && <span className="text-white/40 mc-text-shadow italic">editing: <span className="text-[#FFFF55]">{arc.name}</span></span>}
|
||||
</div>
|
||||
<div className="w-full flex justify-between items-center mb-4 px-8">
|
||||
<h2 className="text-2xl text-white mc-text-shadow border-b-2 border-[#373737] pb-1 tracking-widest uppercase font-bold">
|
||||
ARC Editor
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-lg"
|
||||
onClick={handleFileLoad}
|
||||
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Open ARC
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
disabled={!arc || arc.entries.length === 0}
|
||||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${(!arc || arc.entries.length === 0) ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveArc}
|
||||
disabled={!arc}
|
||||
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!arc ? "opacity-50 grayscale" : ""}`}
|
||||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!arc ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Save ARC
|
||||
@@ -230,6 +352,9 @@ export const ArcEditorView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={injectInputRef} onChange={handleAddEntry} className="hidden" />
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplaceEntry} className="hidden" />
|
||||
|
||||
{!arc ? (
|
||||
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
|
||||
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
|
||||
@@ -244,74 +369,115 @@ export const ArcEditorView: React.FC = () => {
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<img src="/images/tools/arc.png" className={`w-5 h-5 object-contain ${activeTab === "arc" ? "" : "grayscale opacity-50"}`} style={{ imageRendering: "pixelated" }} />
|
||||
<span className="text-lg">Archive</span>
|
||||
<span className="text-lg uppercase tracking-wider font-bold">Archive</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setActiveTab("loc"); }}
|
||||
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "loc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
|
||||
>
|
||||
<img src="/images/tools/loc.png" className={`w-5 h-5 object-contain ${activeTab === "loc" ? "" : "grayscale opacity-50"}`} style={{ imageRendering: "pixelated" }} />
|
||||
<span className="text-lg">Languages (LOC)</span>
|
||||
<span className="text-lg uppercase tracking-wider font-bold">Languages (LOC)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === "arc" ? (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search entries..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add Entry
|
||||
</button>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="w-2/3 flex flex-col p-4 border-r-2 border-black/20">
|
||||
<div className="mb-4 flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search entries..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="px-6 py-2 text-white mc-text-shadow text-sm"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Add Entry
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{renderTree(treeData)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-[#252525] z-10">
|
||||
<tr className="border-b-2 border-[#373737]">
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Offset</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Flags</th>
|
||||
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map((entry) => (
|
||||
<tr key={entry.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
|
||||
<td className="p-3 text-white truncate max-w-md font-medium">{entry.filename}</td>
|
||||
<td className="p-3 text-white/60 text-right font-mono text-xs">0x{entry.ptr.toString(16).toUpperCase().padStart(8, '0')}</td>
|
||||
<td className="p-3 text-white/60 text-right text-xs">{(entry.size / 1024).toFixed(1)} KB</td>
|
||||
<td className="p-3">
|
||||
{entry.isCompressed && (
|
||||
<span className="bg-[#FFFF55]/10 text-[#FFFF55] border border-[#FFFF55]/20 px-2 py-0.5 text-[10px] uppercase font-bold">zlib</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleExtractEntry(entry)} className="p-1 hover:text-[#FFFF55] transition-colors"><img src="/images/Download_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} /></button>
|
||||
<button onClick={() => { setSelectedEntryIdx(entry.originalIdx); setIsReplaceModalOpen(true); }} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase">Replace</button>
|
||||
<button onClick={() => { setSelectedEntryIdx(entry.originalIdx); setIsRenameModalOpen(true); }} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase">Rename</button>
|
||||
<button onClick={() => handleDeleteEntry(entry.originalIdx)} className="p-1 hover:text-red-500 transition-colors opacity-60 hover:opacity-100">
|
||||
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="w-1/3 flex flex-col p-6 overflow-y-auto custom-scrollbar">
|
||||
{selectedEntry ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="p-4 bg-black/40 border-2 border-[#373737]">
|
||||
<h4 className="text-[#FFFF55] mc-text-shadow font-bold text-sm uppercase tracking-widest mb-1">Entry Details</h4>
|
||||
<p className="text-white text-xs break-all font-mono opacity-80">{selectedEntry.filename}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="bg-black/20 p-3">
|
||||
<span className="block text-[10px] text-white/40 uppercase tracking-tighter text-left">Size</span>
|
||||
<span className="text-white text-sm">{(selectedEntry.size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
<div className="bg-black/20 p-3">
|
||||
<span className="block text-[10px] text-white/40 uppercase tracking-tighter text-left">Format</span>
|
||||
<span className="text-white text-sm uppercase">{selectedEntry.isCompressed ? "Compressed" : "Raw"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => handleExtractEntry(selectedEntry)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export File
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsReplaceModalOpen(true)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Replace Data
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsRenameModalOpen(true)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Rename / Path
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!arc) return;
|
||||
const newEntries = [...arc.entries];
|
||||
newEntries[selectedEntryIdx!] = { ...selectedEntry, isCompressed: !selectedEntry.isCompressed };
|
||||
setArc({ ...arc, entries: newEntries });
|
||||
showNotification(`Compression ${!selectedEntry.isCompressed ? "Enabled" : "Disabled"}`);
|
||||
}}
|
||||
className={`w-full py-2 text-white mc-text-shadow text-sm transition-all ${selectedEntry.isCompressed ? "text-[#FFFF55]" : "opacity-60"}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
{selectedEntry.isCompressed ? "ZLIB Compressed" : "Uncompressed"}
|
||||
</button>
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => handleDeleteEntry(selectedEntryIdx!)}
|
||||
className="w-full py-2 text-red-500/80 mc-text-shadow text-sm transition-all hover:text-red-500 hover:scale-[1.02]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Delete Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center opacity-20 italic">
|
||||
<img src="/images/tools/arc.png" className="w-16 h-16 mb-4 grayscale" style={{ imageRendering: "pixelated" }} />
|
||||
<p className="text-white">Select an entry to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden h-full">
|
||||
{!loc ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
|
||||
<h4 className="text-xl text-white/40 mc-text-shadow italic mb-4">No languages.loc found in archive</h4>
|
||||
@@ -401,7 +567,7 @@ export const ArcEditorView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6 h-14">
|
||||
<div className="flex justify-center mt-6 h-14 w-full">
|
||||
<button
|
||||
onClick={() => { playBackSound(); setActiveView("devtools"); }}
|
||||
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
|
||||
|
||||
@@ -4,22 +4,25 @@ import { useUI, useAudio, useConfig } from "../../context/LauncherContext";
|
||||
import { PckService } from "../../services/PckService";
|
||||
import { PCKFile, PCKAsset, PCKAssetType } from "../../types/pck";
|
||||
import SkinPreview3D from "../common/SkinPreview3D";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
|
||||
export default function PckEditorView() {
|
||||
const { setActiveView } = useUI();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const [pck, setPck] = useState<PCKFile | null>(null);
|
||||
const [openedPath, setOpenedPath] = useState<string | null>(null);
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
|
||||
const [showTypeModal, setShowTypeModal] = useState<{ file: File, data: Uint8Array } | null>(null);
|
||||
const [showTypeModal, setShowTypeModal] = useState<{ file: { name: string, data: Uint8Array } } | null>(null);
|
||||
const [isChangingType, setIsChangingType] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const addAssetInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
if (!pck) return [];
|
||||
interface TempNode {
|
||||
@@ -152,19 +155,22 @@ export default function PckEditorView() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileLoad = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const handleFileLoad = async () => {
|
||||
try {
|
||||
const parsed = await PckService.readPCK(buffer);
|
||||
const path = await TauriService.pickFile("Open PCK", ["pck"]);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
const bytes = await TauriService.readBinaryFile(path);
|
||||
const parsed = await PckService.readPCK(bytes.buffer as ArrayBuffer);
|
||||
setPck(parsed);
|
||||
setOpenedPath(path);
|
||||
setSelectedAssetId(parsed.files[0]?.id || null);
|
||||
setExpandedFolders(new Set());
|
||||
} catch (err) {
|
||||
console.error("Failed to parse PCK", err);
|
||||
showNotification("Failed to parse PCK", "error");
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") {
|
||||
console.error("Failed to parse PCK", err);
|
||||
showNotification("Failed to parse PCK", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,27 +184,28 @@ export default function PckEditorView() {
|
||||
files: []
|
||||
};
|
||||
setPck(newPck);
|
||||
setOpenedPath(null);
|
||||
setSelectedAssetId(null);
|
||||
setExpandedFolders(new Set());
|
||||
showNotification("New PCK Created");
|
||||
};
|
||||
|
||||
|
||||
const showNotification = (message: string, type: "success" | "error" = "success") => {
|
||||
setNotification({ message, type });
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleExportAsset = (asset: PCKAsset) => {
|
||||
playPressSound();
|
||||
const blob = new Blob([asset.data as any]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = asset.path.split("/").pop() || "asset";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification(`Exported: ${asset.path.split("/").pop()}`);
|
||||
const handleExportAsset = async (asset: PCKAsset) => {
|
||||
try {
|
||||
const fileName = asset.path.split("/").pop() || "asset";
|
||||
const path = await TauriService.saveFileDialog("Export Asset", fileName, []);
|
||||
if (!path) return;
|
||||
playPressSound();
|
||||
await TauriService.writeBinaryFile(path, asset.data);
|
||||
showNotification(`Exported: ${fileName}`);
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAsset = (id: string) => {
|
||||
@@ -233,19 +240,19 @@ export default function PckEditorView() {
|
||||
playPressSound();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
setShowTypeModal({ file, data });
|
||||
setShowTypeModal({ file: { name: file.name, data } });
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const confirmAddAsset = (type: PCKAssetType) => {
|
||||
if (!pck || !showTypeModal) return;
|
||||
const { file, data } = showTypeModal;
|
||||
const { file } = showTypeModal;
|
||||
const newAsset: PCKAsset = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
path: file.name,
|
||||
type,
|
||||
size: data.length,
|
||||
data,
|
||||
size: file.data.length,
|
||||
data: file.data,
|
||||
properties: []
|
||||
};
|
||||
|
||||
@@ -332,18 +339,66 @@ export default function PckEditorView() {
|
||||
setPck({ ...pck, files: newFiles });
|
||||
};
|
||||
|
||||
const handleSavePCK = () => {
|
||||
const handleRenameAsset = (id: string, newPath: string) => {
|
||||
if (!pck) return;
|
||||
playPressSound();
|
||||
const newFiles = pck.files.map(f => {
|
||||
if (f.id === id) {
|
||||
return { ...f, path: newPath };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
setPck({ ...pck, files: newFiles });
|
||||
showNotification("Asset Renamed");
|
||||
};
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (!pck || pck.files.length === 0) return;
|
||||
try {
|
||||
const baseFolder = await TauriService.pickFolder();
|
||||
if (!baseFolder) return;
|
||||
playPressSound();
|
||||
showNotification("Exporting all assets...");
|
||||
|
||||
for (const asset of pck.files) {
|
||||
const parts = asset.path.split("/");
|
||||
let currentPath = baseFolder;
|
||||
|
||||
// This won't work easily if we need to create subdirectories,
|
||||
// normally we should tell the backend to write with directory creation
|
||||
// but for now let's hope writeBinaryFile handles it if we pass the full path
|
||||
// Actually, write_binary_file in lib.rs uses fs::write which doesn't create parents.
|
||||
// I'll update write_binary_file in lib.rs later if needed.
|
||||
// For now let's just write to the flat folder with the filename.
|
||||
const fileName = parts.join("_");
|
||||
await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, asset.data);
|
||||
}
|
||||
showNotification("All Assets Exported");
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Export failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePCK = async () => {
|
||||
if (!pck) return;
|
||||
playPressSound();
|
||||
const buffer = PckService.serializePCK(pck);
|
||||
const blob = new Blob([buffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = pck.files.length > 0 ? "new.pck" : "empty.pck";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification("PCK Saved Successfully");
|
||||
const data = new Uint8Array(buffer);
|
||||
|
||||
try {
|
||||
let targetPath = openedPath;
|
||||
if (!targetPath) {
|
||||
targetPath = await TauriService.saveFileDialog("Save PCK", pck.files.length > 0 ? "new.pck" : "empty.pck", ["pck"]);
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
await TauriService.writeBinaryFile(targetPath, data);
|
||||
setOpenedPath(targetPath);
|
||||
showNotification("PCK Saved Successfully");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err !== "CANCELED") showNotification("Save failed", "error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -377,6 +432,8 @@ export default function PckEditorView() {
|
||||
}
|
||||
};
|
||||
|
||||
const [isRenamingAsset, setIsRenamingAsset] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
@@ -392,7 +449,7 @@ export default function PckEditorView() {
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={handleFileLoad}
|
||||
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
@@ -405,6 +462,14 @@ export default function PckEditorView() {
|
||||
>
|
||||
New PCK
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
disabled={!pck || pck.files.length === 0}
|
||||
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${(!pck || pck.files.length === 0) ? "opacity-50 grayscale" : ""}`}
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Export All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePCK}
|
||||
disabled={!pck}
|
||||
@@ -416,7 +481,35 @@ export default function PckEditorView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".pck" />
|
||||
{pck && (
|
||||
<div className="w-full flex gap-4 px-8 mb-4">
|
||||
<div className="flex gap-6 bg-black/40 border-2 border-[#373737] p-3 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">Endianness:</span>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setPck({...pck, endianness: pck.endianness === "little" ? "big" : "little"}); }}
|
||||
className="text-[#FFFF55] text-sm uppercase hover:underline"
|
||||
>
|
||||
{pck.endianness}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">XML Support:</span>
|
||||
<button
|
||||
onClick={() => { playPressSound(); setPck({...pck, xmlSupport: !pck.xmlSupport}); }}
|
||||
className={`${pck.xmlSupport ? "text-[#FFFF55]" : "text-white/20"} text-sm uppercase hover:underline`}
|
||||
>
|
||||
{pck.xmlSupport ? "Enabled" : "Disabled"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">Version:</span>
|
||||
<span className="text-white text-sm">{pck.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input type="file" ref={replaceInputRef} onChange={handleReplaceAsset} className="hidden" />
|
||||
<input type="file" ref={addAssetInputRef} onChange={handleAddAsset} className="hidden" />
|
||||
{!pck ? (
|
||||
@@ -678,6 +771,13 @@ export default function PckEditorView() {
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsRenamingAsset(selectedAsset.id)}
|
||||
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Rename Asset (Path)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(selectedAsset.id)}
|
||||
className="w-full py-2 text-red-500/80 mc-text-shadow text-sm transition-all hover:text-red-500 hover:scale-[1.02]"
|
||||
@@ -774,6 +874,73 @@ export default function PckEditorView() {
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{isRenamingAsset && (
|
||||
<RenameAssetModal
|
||||
initialPath={pck?.files.find(f => f.id === isRenamingAsset)?.path || ""}
|
||||
onClose={() => setIsRenamingAsset(null)}
|
||||
onConfirm={(newPath) => {
|
||||
handleRenameAsset(isRenamingAsset, newPath);
|
||||
setIsRenamingAsset(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameAssetModal({ initialPath, onClose, onConfirm }: { initialPath: string, onClose: () => void, onConfirm: (path: string) => void }) {
|
||||
const [path, setPath] = useState(initialPath);
|
||||
return (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
className="relative w-full max-w-lg p-8 flex flex-col"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated"
|
||||
}}
|
||||
>
|
||||
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">Rename Asset</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">New Asset Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(path)}
|
||||
className="px-8 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
|
||||
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,23 @@ export class TauriService {
|
||||
return invoke("pick_folder");
|
||||
}
|
||||
|
||||
static async pickFile(title: string, filters: string[]): Promise<string> {
|
||||
return invoke("pick_file", { title, filters });
|
||||
}
|
||||
|
||||
static async saveFileDialog(title: string, filename: string, filters: string[]): Promise<string> {
|
||||
return invoke("save_file_dialog", { title, filename, filters });
|
||||
}
|
||||
|
||||
static async writeBinaryFile(path: string, data: Uint8Array): Promise<void> {
|
||||
return invoke("write_binary_file", { path, data: Array.from(data) });
|
||||
}
|
||||
|
||||
static async readBinaryFile(path: string): Promise<Uint8Array> {
|
||||
const data: number[] = await invoke("read_binary_file", { path });
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
|
||||
static async downloadLogo(id: string, url: string): Promise<string> {
|
||||
return invoke("download_logo", { id, url });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user