feat: add generic GFF visual editor component
This commit is contained in:
@@ -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 };
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { MonacoEditor } from "../components/editor/MonacoEditor";
|
import { MonacoEditor } from "../components/editor/MonacoEditor";
|
||||||
import { EditorTabs } from "../components/editor/EditorTabs";
|
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 {
|
interface EditorProps {
|
||||||
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
|
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) {
|
export function Editor({ editorState }: EditorProps) {
|
||||||
const {
|
const {
|
||||||
openTabs,
|
openTabs,
|
||||||
@@ -21,8 +32,12 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
closeFile,
|
closeFile,
|
||||||
updateContent,
|
updateContent,
|
||||||
getContent,
|
getContent,
|
||||||
|
markClean,
|
||||||
} = editorState;
|
} = 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(
|
const tabs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
openTabs.map((path: string) => ({
|
openTabs.map((path: string) => ({
|
||||||
@@ -33,11 +48,13 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
|
const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
|
||||||
const activeFilePath = activeTab
|
const activeFilePath = activeTab ? filePathFromTabKey(activeTab) : "";
|
||||||
? activeTab.includes(":")
|
const activeRepo = activeTab ? repoFromTabKey(activeTab) : "";
|
||||||
? activeTab.split(":").slice(1).join(":")
|
|
||||||
: activeTab
|
const isActiveGff = activeTab ? isGffFile(activeTab) : false;
|
||||||
: "";
|
const activeMode = activeTab
|
||||||
|
? editorModes[activeTab] ?? (isActiveGff ? "visual" : "raw")
|
||||||
|
: "raw";
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -48,6 +65,80 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
[activeTab, updateContent],
|
[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 (
|
return (
|
||||||
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||||
<EditorTabs
|
<EditorTabs
|
||||||
@@ -57,20 +148,7 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
onClose={closeFile}
|
onClose={closeFile}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{activeTab ? (
|
{renderEditor()}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const api = {
|
|||||||
totalMatches: number;
|
totalMatches: number;
|
||||||
filesSearched: number;
|
filesSearched: number;
|
||||||
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
|
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
|
||||||
|
gffSchema: (type: string) =>
|
||||||
|
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
workspace: {
|
workspace: {
|
||||||
|
|||||||
Reference in New Issue
Block a user