feat: add specialized visual editors for items, creatures, areas, and dialogs
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user