feat: add generic GFF visual editor component

This commit is contained in:
plenarius
2026-04-20 20:09:24 -04:00
parent c7f5646e62
commit 5620e38282
3 changed files with 654 additions and 26 deletions
@@ -0,0 +1,548 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { api } from "../../services/api";
export enum GffFieldType {
Byte = 0,
Char = 1,
Word = 2,
Short = 3,
Dword = 4,
Int = 5,
Dword64 = 6,
Int64 = 7,
Float = 8,
Double = 9,
CExoString = 10,
ResRef = 11,
CExoLocString = 12,
Void = 13,
Struct = 14,
List = 15,
}
export interface GffFieldSchema {
label: string;
displayName: string;
type: GffFieldType;
description?: string;
category?: string;
editable?: boolean;
hidden?: boolean;
fields?: GffFieldSchema[];
}
export interface GffTypeSchema {
fileType: string;
displayName: string;
categories: string[];
fields: GffFieldSchema[];
}
function gffTypeFromPath(filePath: string): string | null {
const match = filePath.match(/\.(\w+)\.json$/);
return match ? match[1] : null;
}
function getFieldValue(data: Record<string, unknown>, label: string): unknown {
const field = data[label];
if (field && typeof field === "object" && "value" in (field as Record<string, unknown>)) {
return (field as Record<string, unknown>).value;
}
return field;
}
function getLocStringText(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
const v = value as Record<string, unknown>;
if (v.strings && typeof v.strings === "object") {
const strings = v.strings as Record<string, string>;
return strings["0"] ?? Object.values(strings)[0] ?? "";
}
if (v.value && typeof v.value === "object") {
return getLocStringText(v.value);
}
}
return String(value);
}
function setFieldValue(
data: Record<string, unknown>,
label: string,
newValue: unknown,
): Record<string, unknown> {
const updated = { ...data };
const existing = data[label];
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
updated[label] = { ...(existing as Record<string, unknown>), value: newValue };
} else {
updated[label] = newValue;
}
return updated;
}
function setLocStringValue(
data: Record<string, unknown>,
label: string,
text: string,
): Record<string, unknown> {
const updated = { ...data };
const existing = data[label];
if (existing && typeof existing === "object") {
const ex = existing as Record<string, unknown>;
if ("value" in ex && ex.value && typeof ex.value === "object") {
const inner = ex.value as Record<string, unknown>;
updated[label] = {
...ex,
value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } },
};
} else if ("strings" in ex) {
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
} else {
updated[label] = { ...ex, value: { strings: { "0": text } } };
}
} else {
updated[label] = { type: "cexolocstring", value: { strings: { "0": text } } };
}
return updated;
}
function isNumericType(type: GffFieldType): boolean {
return type <= GffFieldType.Double;
}
interface FieldRendererProps {
field: GffFieldSchema;
value: unknown;
onChange: (value: unknown) => void;
onLocStringChange?: (text: string) => void;
}
function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRendererProps) {
if (field.hidden) return null;
const isReadonly = field.editable === false;
if (field.type === GffFieldType.Void) {
const hex = typeof value === "string" ? value : "";
return (
<div className="flex items-start gap-3">
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<code
className="flex-1 rounded px-2 py-1.5 text-xs break-all"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
{hex || "(empty)"}
</code>
</div>
);
}
if (field.type === GffFieldType.CExoLocString) {
const text = getLocStringText(value);
return (
<div className="flex items-start gap-3">
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="text"
value={text}
readOnly={isReadonly}
onChange={(e) => onLocStringChange?.(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
);
}
if (field.type === GffFieldType.List) {
const list = Array.isArray(value) ? value : [];
return <ListField field={field} items={list} />;
}
if (field.type === GffFieldType.Struct) {
return <StructField field={field} value={value as Record<string, unknown> | undefined} />;
}
if (isNumericType(field.type)) {
const num = typeof value === "number" ? value : 0;
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="number"
value={num}
readOnly={isReadonly}
step={isFloat ? "0.01" : "1"}
onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))}
className="w-32 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
);
}
if (field.type === GffFieldType.ResRef) {
const str = typeof value === "string" ? value : "";
const valid = str.length <= 16;
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<div className="flex flex-1 items-center gap-2">
<input
type="text"
value={str}
readOnly={isReadonly}
maxLength={16}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: valid ? "var(--forge-border)" : "#ef4444",
color: "var(--forge-text)",
}}
/>
<span
className="text-xs"
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }}
>
{str.length}/16
</span>
</div>
</div>
);
}
// CExoString fallback
const str = typeof value === "string" ? value : String(value ?? "");
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="text"
value={str}
readOnly={isReadonly}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
);
}
function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="flex flex-col gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
<span className="rounded px-1.5 py-0.5 text-xs" style={{ backgroundColor: "var(--forge-bg)" }}>
{items.length} {items.length === 1 ? "item" : "items"}
</span>
</button>
{expanded && (
<div
className="ml-4 mt-1 space-y-2 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
{items.map((item, i) => (
<div key={i} className="rounded p-2" style={{ backgroundColor: "var(--forge-bg)" }}>
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
[{i}]
</div>
{typeof item === "object" && item !== null ? (
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(item, null, 2)}
</pre>
) : (
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
{String(item)}
</span>
)}
</div>
))}
{items.length === 0 && (
<div className="py-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
(empty list)
</div>
)}
</div>
)}
</div>
);
}
function StructField({ field, value }: { field: GffFieldSchema; value?: Record<string, unknown> }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="flex flex-col gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
</button>
{expanded && value && (
<div
className="ml-4 mt-1 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(value, null, 2)}
</pre>
</div>
)}
</div>
);
}
interface GffEditorProps {
repo: string;
filePath: string;
content: string;
onSave?: (content: string) => void;
onSwitchToRaw?: () => void;
fieldOverrides?: Map<string, (props: FieldOverrideProps) => React.ReactNode>;
headerSlot?: React.ReactNode;
}
export interface FieldOverrideProps {
field: GffFieldSchema;
value: unknown;
data: Record<string, unknown>;
onChange: (label: string, value: unknown) => void;
}
export function GffEditor({
repo,
filePath,
content,
onSave,
onSwitchToRaw,
fieldOverrides,
headerSlot,
}: GffEditorProps) {
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
const [data, setData] = useState<Record<string, unknown>>({});
const [activeCategory, setActiveCategory] = useState<string>("");
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
useEffect(() => {
try {
setData(JSON.parse(content));
} catch {
setError("Failed to parse JSON content");
}
}, [content]);
useEffect(() => {
if (!gffType) return;
api.editor
.gffSchema(gffType)
.then((s) => {
setSchema(s);
if (s.categories.length > 0) setActiveCategory(s.categories[0]);
})
.catch(() => setError(`Failed to load schema for .${gffType}`));
}, [gffType]);
const handleFieldChange = useCallback((label: string, value: unknown) => {
setData((prev) => {
const updated = setFieldValue(prev, label, value);
setDirty(true);
return updated;
});
}, []);
const handleLocStringChange = useCallback((label: string, text: string) => {
setData((prev) => {
const updated = setLocStringValue(prev, label, text);
setDirty(true);
return updated;
});
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const newContent = JSON.stringify(data, null, 4) + "\n";
await api.editor.writeFile(repo, filePath, newContent);
setDirty(false);
onSave?.(newContent);
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}, [data, repo, filePath, onSave]);
const categoryFields = useMemo(() => {
if (!schema) return [];
return schema.fields.filter((f) => f.category === activeCategory && !f.hidden);
}, [schema, activeCategory]);
if (error && !schema) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p>
{onSwitchToRaw && (
<button
onClick={onSwitchToRaw}
className="mt-3 rounded px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-surface)", color: "var(--forge-text)" }}
>
Open as Raw JSON
</button>
)}
</div>
</div>
);
}
if (!schema) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Loading schema...</p>
</div>
);
}
return (
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
{/* Toolbar */}
<div
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
{schema.displayName} Editor
</span>
{dirty && (
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
(unsaved changes)
</span>
)}
{error && (
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span>
)}
</div>
<div className="flex items-center gap-2">
{onSwitchToRaw && (
<button
onClick={onSwitchToRaw}
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
Switch to Raw JSON
</button>
)}
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{
backgroundColor: "var(--forge-accent)",
color: "#fff",
}}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
{headerSlot}
{/* Category tabs */}
<div
className="flex shrink-0 gap-0 overflow-x-auto border-b"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
{schema.categories.map((cat) => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className="shrink-0 px-4 py-2 text-sm transition-colors"
style={{
color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)",
borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent",
}}
>
{cat}
</button>
))}
</div>
{/* Fields */}
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-2xl space-y-4">
{categoryFields.map((field) => {
const override = fieldOverrides?.get(field.label);
if (override) {
return (
<div key={field.label}>
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
</div>
);
}
return (
<div key={field.label} title={field.description}>
<FieldRenderer
field={field}
value={getFieldValue(data, field.label)}
onChange={(val) => handleFieldChange(field.label, val)}
onLocStringChange={(text) => handleLocStringChange(field.label, text)}
/>
</div>
);
})}
{categoryFields.length === 0 && (
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
No fields in this category
</p>
)}
</div>
</div>
</div>
);
}
export { gffTypeFromPath, getFieldValue, getLocStringText, setFieldValue, setLocStringValue };
+104 -26
View File
@@ -1,17 +1,28 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { MonacoEditor } from "../components/editor/MonacoEditor";
import { EditorTabs } from "../components/editor/EditorTabs";
import { GffEditor } from "../components/gff/GffEditor";
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
function isGffFile(tabKey: string): boolean {
return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext));
}
function repoFromTabKey(tabKey: string): string {
const idx = tabKey.indexOf(":");
return idx > 0 ? tabKey.slice(0, idx) : "";
}
function filePathFromTabKey(tabKey: string): string {
const idx = tabKey.indexOf(":");
return idx > 0 ? tabKey.slice(idx + 1) : tabKey;
}
interface EditorProps {
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
}
function displayName(tabKey: string): string {
const parts = tabKey.split(":");
const filePath = parts.length > 1 ? parts.slice(1).join(":") : tabKey;
return filePath.split("/").pop() ?? filePath;
}
export function Editor({ editorState }: EditorProps) {
const {
openTabs,
@@ -21,8 +32,12 @@ export function Editor({ editorState }: EditorProps) {
closeFile,
updateContent,
getContent,
markClean,
} = editorState;
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
const tabs = useMemo(
() =>
openTabs.map((path: string) => ({
@@ -33,11 +48,13 @@ export function Editor({ editorState }: EditorProps) {
);
const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
const activeFilePath = activeTab
? activeTab.includes(":")
? activeTab.split(":").slice(1).join(":")
: activeTab
: "";
const activeFilePath = activeTab ? filePathFromTabKey(activeTab) : "";
const activeRepo = activeTab ? repoFromTabKey(activeTab) : "";
const isActiveGff = activeTab ? isGffFile(activeTab) : false;
const activeMode = activeTab
? editorModes[activeTab] ?? (isActiveGff ? "visual" : "raw")
: "raw";
const handleChange = useCallback(
(value: string) => {
@@ -48,6 +65,80 @@ export function Editor({ editorState }: EditorProps) {
[activeTab, updateContent],
);
const handleSwitchToRaw = useCallback(() => {
if (activeTab) {
setEditorModes((prev) => ({ ...prev, [activeTab]: "raw" }));
}
}, [activeTab]);
const handleSwitchToVisual = useCallback(() => {
if (activeTab) {
setEditorModes((prev) => ({ ...prev, [activeTab]: "visual" }));
}
}, [activeTab]);
const handleGffSave = useCallback(
(newContent: string) => {
if (activeTab) {
updateContent(activeTab, newContent);
markClean(activeTab);
}
},
[activeTab, updateContent, markClean],
);
const renderEditor = () => {
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center">
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
Open a file from the File Explorer to start editing
</p>
</div>
);
}
if (isActiveGff && activeMode === "visual") {
return (
<GffEditor
key={activeTab}
repo={activeRepo}
filePath={activeFilePath}
content={activeContent}
onSave={handleGffSave}
onSwitchToRaw={handleSwitchToRaw}
/>
);
}
return (
<div className="flex h-full flex-col">
{isActiveGff && activeMode === "raw" && (
<div
className="flex shrink-0 items-center justify-end border-b px-4 py-1"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
<button
onClick={handleSwitchToVisual}
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
Switch to Visual Editor
</button>
</div>
)}
<div className="flex-1 overflow-hidden">
<MonacoEditor
key={activeTab}
filePath={activeFilePath}
content={activeContent}
onChange={handleChange}
/>
</div>
</div>
);
};
return (
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<EditorTabs
@@ -57,20 +148,7 @@ export function Editor({ editorState }: EditorProps) {
onClose={closeFile}
/>
<div className="flex-1 overflow-hidden">
{activeTab ? (
<MonacoEditor
key={activeTab}
filePath={activeFilePath}
content={activeContent}
onChange={handleChange}
/>
) : (
<div className="flex h-full items-center justify-center">
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
Open a file from the File Explorer to start editing
</p>
</div>
)}
{renderEditor()}
</div>
</div>
);
+2
View File
@@ -39,6 +39,8 @@ export const api = {
totalMatches: number;
filesSearched: number;
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
gffSchema: (type: string) =>
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
},
workspace: {