feat: add specialized visual editors for items, creatures, areas, and dialogs

This commit is contained in:
plenarius
2026-04-20 20:11:44 -04:00
parent 5620e38282
commit 6e3abb0b07
5 changed files with 1064 additions and 10 deletions
@@ -0,0 +1,197 @@
import { useMemo } from "react";
import {
GffEditor,
getFieldValue,
type FieldOverrideProps,
} from "./GffEditor";
interface AreaEditorProps {
repo: string;
filePath: string;
content: string;
onSave?: (content: string) => void;
onSwitchToRaw?: () => void;
}
function intToHexColor(value: number): string {
const r = value & 0xff;
const g = (value >> 8) & 0xff;
const b = (value >> 16) & 0xff;
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
function hexColorToInt(hex: string): number {
const clean = hex.replace("#", "");
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
return r | (g << 8) | (b << 16);
}
function FlagsOverride({ data, onChange }: FieldOverrideProps) {
const raw = getFieldValue(data, "Flags");
const flags = typeof raw === "number" ? raw : 0;
const bits = [
{ bit: 0, label: "Interior" },
{ bit: 1, label: "Underground" },
{ bit: 2, label: "Natural" },
];
return (
<div className="space-y-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Area Flags
</label>
<div className="flex items-center gap-6">
{bits.map(({ bit, label }) => {
const checked = (flags & (1 << bit)) !== 0;
return (
<label key={bit} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const newFlags = e.target.checked
? flags | (1 << bit)
: flags & ~(1 << bit);
onChange("Flags", newFlags);
}}
className="h-4 w-4 rounded"
style={{ accentColor: "var(--forge-accent)" }}
/>
<span style={{ color: "var(--forge-text)" }}>{label}</span>
</label>
);
})}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Raw value: {flags}
</span>
</div>
);
}
function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
const num = typeof value === "number" ? value : 0;
const hex = intToHexColor(num);
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 items-center gap-2">
<input
type="color"
value={hex}
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
className="h-8 w-10 cursor-pointer rounded border-0"
style={{ backgroundColor: "var(--forge-bg)" }}
/>
<span className="font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{hex}
</span>
</div>
</div>
);
}
function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
const width = getFieldValue(data, "Width");
const height = getFieldValue(data, "Height");
const tileset = getFieldValue(data, "Tileset");
const w = typeof width === "number" ? width : 0;
const h = typeof height === "number" ? height : 0;
const ts = typeof tileset === "string" ? tileset : "";
return (
<div className="space-y-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Width</label>
<input
type="number"
value={w}
min={1}
max={32}
onChange={(e) => onChange("Width", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
<span style={{ color: "var(--forge-text-secondary)" }}>×</span>
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Height</label>
<input
type="number"
value={h}
min={1}
max={32}
onChange={(e) => onChange("Height", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>tiles</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Tileset</label>
<input
type="text"
value={ts}
maxLength={16}
onChange={(e) => onChange("Tileset", e.target.value)}
className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
</div>
);
}
const COLOR_FIELDS = [
"SunAmbientColor", "SunDiffuseColor", "SunFogColor",
"MoonAmbientColor", "MoonDiffuseColor", "MoonFogColor",
];
export function AreaEditor({ repo, filePath, content, onSave, onSwitchToRaw }: AreaEditorProps) {
const fieldOverrides = useMemo(() => {
const overrides = new Map<string, (props: FieldOverrideProps) => React.ReactNode>();
overrides.set("Flags", (props) => <FlagsOverride {...props} />);
overrides.set("Width", (props) => <DimensionsOverride {...props} />);
overrides.set("Height", () => null);
overrides.set("Tileset", () => null);
for (const cf of COLOR_FIELDS) {
overrides.set(cf, (props) => <ColorOverride {...props} />);
}
return overrides;
}, []);
return (
<GffEditor
repo={repo}
filePath={filePath}
content={content}
onSave={onSave}
onSwitchToRaw={onSwitchToRaw}
fieldOverrides={fieldOverrides}
/>
);
}
@@ -0,0 +1,185 @@
import { useMemo } from "react";
import {
GffEditor,
getFieldValue,
type FieldOverrideProps,
} from "./GffEditor";
interface CreatureEditorProps {
repo: string;
filePath: string;
content: string;
onSave?: (content: string) => void;
onSwitchToRaw?: () => void;
}
function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) {
const abilities = [
["Str", "Dex", "Con"],
["Int", "Wis", "Cha"],
];
const displayNames: Record<string, string> = {
Str: "STR", Dex: "DEX", Con: "CON",
Int: "INT", Wis: "WIS", Cha: "CHA",
};
return (
<div className="space-y-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Ability Scores
</label>
<div className="grid grid-cols-3 gap-3">
{abilities.flat().map((ab) => {
const val = getFieldValue(data, ab);
const num = typeof val === "number" ? val : 0;
return (
<div
key={ab}
className="flex flex-col items-center rounded border px-3 py-2"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
>
<span className="text-xs font-bold" style={{ color: "var(--forge-text-secondary)" }}>
{displayNames[ab]}
</span>
<input
type="number"
value={num}
min={1}
max={99}
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))}
className="mt-1 w-16 rounded border px-1 py-1 text-center text-lg font-semibold"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<span className="mt-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
</span>
</div>
);
})}
</div>
</div>
);
}
function RaceGenderOverride({ data, onChange }: FieldOverrideProps) {
const race = getFieldValue(data, "Race");
const gender = getFieldValue(data, "Gender");
const raceNum = typeof race === "number" ? race : 0;
const genderNum = typeof gender === "number" ? gender : 0;
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Race</label>
<input
type="number"
value={raceNum}
min={0}
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
(racialtypes.2da)
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Gender</label>
<select
value={genderNum}
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
className="rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
>
<option value={0}>Male</option>
<option value={1}>Female</option>
</select>
</div>
</div>
);
}
function ScriptsOverride({ data, onChange }: FieldOverrideProps) {
const scripts = [
{ label: "ScriptHeartbeat", display: "Heartbeat" },
{ label: "ScriptOnDamaged", display: "On Damaged" },
{ label: "ScriptDeath", display: "On Death" },
{ label: "ScriptSpawn", display: "On Spawn" },
];
return (
<div className="space-y-2">
{scripts.map((s) => {
const val = getFieldValue(data, s.label);
const str = typeof val === "string" ? val : "";
return (
<div key={s.label} className="flex items-center gap-3">
<label className="w-28 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{s.display}
</label>
<input
type="text"
value={str}
maxLength={16}
onChange={(e) => onChange(s.label, e.target.value)}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
placeholder="(none)"
/>
</div>
);
})}
</div>
);
}
export function CreatureEditor({ repo, filePath, content, onSave, onSwitchToRaw }: CreatureEditorProps) {
const fieldOverrides = useMemo(() => {
const overrides = new Map<string, (props: FieldOverrideProps) => React.ReactNode>();
overrides.set("Str", (props) => <AbilityScoresOverride {...props} />);
overrides.set("Dex", () => null);
overrides.set("Con", () => null);
overrides.set("Int", () => null);
overrides.set("Wis", () => null);
overrides.set("Cha", () => null);
overrides.set("Race", (props) => <RaceGenderOverride {...props} />);
overrides.set("Gender", () => null);
overrides.set("ScriptHeartbeat", (props) => <ScriptsOverride {...props} />);
overrides.set("ScriptOnDamaged", () => null);
overrides.set("ScriptDeath", () => null);
overrides.set("ScriptSpawn", () => null);
return overrides;
}, []);
return (
<GffEditor
repo={repo}
filePath={filePath}
content={content}
onSave={onSave}
onSwitchToRaw={onSwitchToRaw}
fieldOverrides={fieldOverrides}
/>
);
}
@@ -0,0 +1,437 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { api } from "../../services/api";
import {
type GffTypeSchema,
GffFieldType,
gffTypeFromPath,
getLocStringText,
} from "./GffEditor";
interface DialogEditorProps {
repo: string;
filePath: string;
content: string;
onSave?: (content: string) => void;
onSwitchToRaw?: () => void;
}
interface DialogNode {
__struct_id?: number;
Text?: unknown;
Speaker?: unknown;
Animation?: unknown;
Sound?: unknown;
Script?: unknown;
Active?: unknown;
RepliesList?: unknown[];
EntriesList?: unknown[];
[key: string]: unknown;
}
function getStringVal(node: DialogNode, key: string): string {
const v = node[key];
if (v && typeof v === "object" && "value" in (v as Record<string, unknown>)) {
return String((v as Record<string, unknown>).value ?? "");
}
return typeof v === "string" ? v : "";
}
function getTextVal(node: DialogNode): string {
return getLocStringText(node.Text);
}
function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply" }) {
const text = getTextVal(node);
const speaker = getStringVal(node, "Speaker");
const script = getStringVal(node, "Script");
const active = getStringVal(node, "Active");
const sound = getStringVal(node, "Sound");
return (
<div className="space-y-2">
<div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Text
</label>
<p className="mt-0.5 text-sm" style={{ color: "var(--forge-text)" }}>
{text || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{type === "entry" && speaker && (
<div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Speaker</label>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{speaker}</p>
</div>
)}
{script && (
<div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Action Script</label>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{script}</p>
</div>
)}
{active && (
<div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Condition</label>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{active}</p>
</div>
)}
{sound && (
<div>
<label className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>Sound</label>
<p className="font-mono text-xs" style={{ color: "var(--forge-text)" }}>{sound}</p>
</div>
)}
</div>
</div>
);
}
function DialogNodeItem({
node,
index,
type,
entries,
replies,
depth,
}: {
node: DialogNode;
index: number;
type: "entry" | "reply";
entries: DialogNode[];
replies: DialogNode[];
depth: number;
}) {
const [expanded, setExpanded] = useState(false);
const text = getTextVal(node);
const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text;
const childLinks = type === "entry"
? (node.RepliesList as Array<{ Index?: unknown; value?: unknown }> | undefined)
: (node.EntriesList as Array<{ Index?: unknown; value?: unknown }> | undefined);
const childType = type === "entry" ? "reply" : "entry";
const childPool = childType === "entry" ? entries : replies;
return (
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:opacity-80"
style={{
backgroundColor: expanded ? "var(--forge-surface)" : "transparent",
color: "var(--forge-text)",
}}
>
<span className="mt-0.5 font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{expanded ? "▼" : "▶"}
</span>
<span
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
style={{
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20",
color: type === "entry" ? "#60a5fa" : "#4ade80",
}}
>
{type === "entry" ? "E" : "R"}{index}
</span>
<span className="flex-1 truncate">
{truncated || <span style={{ color: "var(--forge-text-secondary)" }}>(empty)</span>}
</span>
</button>
{expanded && (
<div
className="ml-6 mt-1 space-y-2 border-l-2 pl-3"
style={{ borderColor: "var(--forge-border)" }}
>
<div className="rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
<NodeDetail node={node} type={type} />
</div>
{childLinks && childLinks.length > 0 && depth < 4 && (
<div className="space-y-1">
{childLinks.map((link, li) => {
const idx = typeof link === "object" && link !== null
? (typeof link.Index === "number" ? link.Index :
typeof link.value === "number" ? link.value :
typeof (link as Record<string, unknown>).Index === "object"
? ((link as Record<string, unknown>).Index as Record<string, unknown>)?.value as number
: li)
: li;
const childIdx = typeof idx === "number" ? idx : li;
const child = childPool[childIdx];
if (!child) return null;
return (
<DialogNodeItem
key={li}
node={child}
index={childIdx}
type={childType}
entries={entries}
replies={replies}
depth={depth + 1}
/>
);
})}
</div>
)}
</div>
)}
</div>
);
}
export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }: DialogEditorProps) {
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
const [data, setData] = useState<Record<string, unknown>>({});
const [activeTab, setActiveTab] = useState<"tree" | "properties">("tree");
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 dialog JSON");
}
}, [content]);
useEffect(() => {
if (!gffType) return;
api.editor.gffSchema(gffType).then(setSchema).catch(() => {});
}, [gffType]);
const entries = useMemo(() => {
const raw = data.EntryList;
if (raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)) {
const v = (raw as Record<string, unknown>).value;
return Array.isArray(v) ? (v as DialogNode[]) : [];
}
return Array.isArray(raw) ? (raw as DialogNode[]) : [];
}, [data]);
const replies = useMemo(() => {
const raw = data.ReplyList;
if (raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)) {
const v = (raw as Record<string, unknown>).value;
return Array.isArray(v) ? (v as DialogNode[]) : [];
}
return Array.isArray(raw) ? (raw as DialogNode[]) : [];
}, [data]);
const startingEntries = useMemo(() => {
const raw = data.StartingList;
if (raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)) {
const v = (raw as Record<string, unknown>).value;
return Array.isArray(v) ? v : [];
}
return Array.isArray(raw) ? raw : [];
}, [data]);
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 propertyFields = useMemo(() => {
if (!schema) return [];
return schema.fields.filter(
(f) => f.category === "Properties" && f.type !== GffFieldType.List,
);
}, [schema]);
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)" }}>
Dialog Editor
</span>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{entries.length} entries, {replies.length} replies
</span>
{dirty && (
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
(unsaved changes)
</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>
{/* Tabs */}
<div
className="flex shrink-0 gap-0 border-b"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
{(["tree", "properties"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="px-4 py-2 text-sm capitalize transition-colors"
style={{
color: activeTab === tab ? "var(--forge-text)" : "var(--forge-text-secondary)",
borderBottom: activeTab === tab ? "2px solid var(--forge-accent)" : "2px solid transparent",
}}
>
{tab === "tree" ? "Conversation Tree" : "Properties"}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<p className="mb-4 text-sm" style={{ color: "#ef4444" }}>{error}</p>
)}
{activeTab === "tree" && (
<div className="mx-auto max-w-3xl space-y-1">
{startingEntries.length > 0 ? (
startingEntries.map((link, i) => {
const idx = typeof link === "object" && link !== null
? ((link as Record<string, unknown>).Index as number ??
((link as Record<string, Record<string, unknown>>).Index?.value as number) ??
i)
: i;
const entry = entries[typeof idx === "number" ? idx : i];
if (!entry) return null;
return (
<DialogNodeItem
key={i}
node={entry}
index={typeof idx === "number" ? idx : i}
type="entry"
entries={entries}
replies={replies}
depth={0}
/>
);
})
) : entries.length > 0 ? (
<DialogNodeItem
node={entries[0]}
index={0}
type="entry"
entries={entries}
replies={replies}
depth={0}
/>
) : (
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
No dialog entries found
</p>
)}
</div>
)}
{activeTab === "properties" && (
<div className="mx-auto max-w-2xl space-y-4">
{propertyFields.map((field) => {
const raw = data[field.label];
const value = raw && typeof raw === "object" && "value" in (raw as Record<string, unknown>)
? (raw as Record<string, unknown>).value
: raw;
return (
<div key={field.label} className="flex items-center gap-3" title={field.description}>
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
{field.type === GffFieldType.ResRef ? (
<input
type="text"
value={typeof value === "string" ? value : ""}
maxLength={16}
readOnly={field.editable === false}
onChange={(e) => {
setData((prev) => {
const existing = prev[field.label];
const updated = { ...prev };
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
updated[field.label] = { ...(existing as Record<string, unknown>), value: e.target.value };
} else {
updated[field.label] = e.target.value;
}
setDirty(true);
return updated;
});
}}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
) : (
<input
type="number"
value={typeof value === "number" ? value : 0}
readOnly={field.editable === false}
onChange={(e) => {
setData((prev) => {
const existing = prev[field.label];
const updated = { ...prev };
const num = parseInt(e.target.value, 10);
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
updated[field.label] = { ...(existing as Record<string, unknown>), value: num };
} else {
updated[field.label] = num;
}
setDirty(true);
return updated;
});
}}
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>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,208 @@
import { useMemo } from "react";
import {
GffEditor,
GffFieldType,
getFieldValue,
getLocStringText,
type FieldOverrideProps,
} from "./GffEditor";
interface ItemEditorProps {
repo: string;
filePath: string;
content: string;
onSave?: (content: string) => void;
onSwitchToRaw?: () => void;
}
function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) {
const num = typeof value === "number" ? value : 0;
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}
onChange={(e) => onChange(field.label, parseInt(e.target.value, 10))}
className="w-24 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
(baseitems.2da row)
</span>
</div>
);
}
function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideProps) {
const stackSize = typeof getFieldValue(data, "StackSize") === "number"
? (getFieldValue(data, "StackSize") as number) : 0;
const cost = typeof getFieldValue(data, "Cost") === "number"
? (getFieldValue(data, "Cost") as number) : 0;
const charges = typeof getFieldValue(data, "Charges") === "number"
? (getFieldValue(data, "Charges") as number) : 0;
if (field.label !== "StackSize") return null;
return (
<div className="flex items-center gap-4">
{[
{ label: "StackSize", display: "Stack", value: stackSize, max: 99 },
{ label: "Cost", display: "Cost (gp)", value: cost, max: 999999 },
{ label: "Charges", display: "Charges", value: charges, max: 255 },
].map((item) => (
<div key={item.label} className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{item.display}
</label>
<input
type="number"
value={item.value}
min={0}
max={item.max}
onChange={(e) => onChange(item.label, parseInt(e.target.value, 10))}
className="w-24 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
))}
</div>
);
}
function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) {
const flags = [
{ label: "Identified", display: "Identified" },
{ label: "Plot", display: "Plot" },
{ label: "Stolen", display: "Stolen" },
{ label: "Cursed", display: "Cursed" },
];
return (
<div className="flex items-center gap-6">
{flags.map((flag) => {
const val = getFieldValue(data, flag.label);
const checked = typeof val === "number" ? val !== 0 : Boolean(val);
return (
<label key={flag.label} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(flag.label, e.target.checked ? 1 : 0)}
className="h-4 w-4 rounded"
style={{ accentColor: "var(--forge-accent)" }}
/>
<span style={{ color: "var(--forge-text)" }}>{flag.display}</span>
</label>
);
})}
</div>
);
}
function PropertiesListOverride({ value }: FieldOverrideProps) {
const list = Array.isArray(value) ? value : [];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
Item Properties
</span>
<div className="flex gap-2">
<button
className="rounded px-2 py-1 text-xs"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
+ Add Property
</button>
</div>
</div>
{list.map((prop, i) => (
<div
key={i}
className="flex items-center gap-2 rounded border px-3 py-2"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-bg)" }}
>
<span className="flex-1 font-mono text-xs" style={{ color: "var(--forge-text)" }}>
{typeof prop === "object" && prop !== null
? JSON.stringify(prop).slice(0, 80)
: String(prop)}
</span>
<button
className="text-xs"
style={{ color: "#ef4444" }}
>
Remove
</button>
</div>
))}
{list.length === 0 && (
<p className="py-2 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
No item properties
</p>
)}
</div>
);
}
export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: ItemEditorProps) {
const fieldOverrides = useMemo(() => {
const overrides = new Map<string, (props: FieldOverrideProps) => React.ReactNode>();
overrides.set("BaseItem", (props) => <BaseItemOverride {...props} />);
overrides.set("StackSize", (props) => <CompactNumbersOverride {...props} />);
overrides.set("Cost", () => null);
overrides.set("Charges", () => null);
overrides.set("Identified", (props) => <BooleanFlagsOverride {...props} />);
overrides.set("Plot", () => null);
overrides.set("Stolen", () => null);
overrides.set("Cursed", () => null);
overrides.set("PropertiesList", (props) => <PropertiesListOverride {...props} />);
return overrides;
}, []);
const itemName = useMemo(() => {
try {
const data = JSON.parse(content);
const nameField = data.LocalizedName;
return getLocStringText(nameField) || "(unnamed item)";
} catch {
return "(unnamed item)";
}
}, [content]);
const headerSlot = (
<div
className="border-b px-4 py-3"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
<h2 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
{itemName}
</h2>
</div>
);
return (
<GffEditor
repo={repo}
filePath={filePath}
content={content}
onSave={onSave}
onSwitchToRaw={onSwitchToRaw}
fieldOverrides={fieldOverrides}
headerSlot={headerSlot}
/>
);
}
+37 -10
View File
@@ -2,13 +2,28 @@ import { useCallback, useMemo, useState } from "react";
import { MonacoEditor } from "../components/editor/MonacoEditor";
import { EditorTabs } from "../components/editor/EditorTabs";
import { GffEditor } from "../components/gff/GffEditor";
import { ItemEditor } from "../components/gff/ItemEditor";
import { CreatureEditor } from "../components/gff/CreatureEditor";
import { AreaEditor } from "../components/gff/AreaEditor";
import { DialogEditor } from "../components/gff/DialogEditor";
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
type GffEditorType = "uti" | "utc" | "are" | "dlg" | "generic" | null;
function isGffFile(tabKey: string): boolean {
return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext));
}
function getGffEditorType(tabKey: string): GffEditorType {
if (tabKey.endsWith(".uti.json")) return "uti";
if (tabKey.endsWith(".utc.json")) return "utc";
if (tabKey.endsWith(".are.json")) return "are";
if (tabKey.endsWith(".dlg.json")) return "dlg";
if (tabKey.endsWith(".utp.json") || tabKey.endsWith(".utm.json")) return "generic";
return null;
}
function repoFromTabKey(tabKey: string): string {
const idx = tabKey.indexOf(":");
return idx > 0 ? tabKey.slice(0, idx) : "";
@@ -99,16 +114,28 @@ export function Editor({ editorState }: EditorProps) {
}
if (isActiveGff && activeMode === "visual") {
return (
<GffEditor
key={activeTab}
repo={activeRepo}
filePath={activeFilePath}
content={activeContent}
onSave={handleGffSave}
onSwitchToRaw={handleSwitchToRaw}
/>
);
const editorType = getGffEditorType(activeTab);
const commonProps = {
key: activeTab,
repo: activeRepo,
filePath: activeFilePath,
content: activeContent,
onSave: handleGffSave,
onSwitchToRaw: handleSwitchToRaw,
};
switch (editorType) {
case "uti":
return <ItemEditor {...commonProps} />;
case "utc":
return <CreatureEditor {...commonProps} />;
case "are":
return <AreaEditor {...commonProps} />;
case "dlg":
return <DialogEditor {...commonProps} />;
default:
return <GffEditor {...commonProps} />;
}
}
return (