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 { MonacoEditor } from "../components/editor/MonacoEditor";
|
||||||
import { EditorTabs } from "../components/editor/EditorTabs";
|
import { EditorTabs } from "../components/editor/EditorTabs";
|
||||||
import { GffEditor } from "../components/gff/GffEditor";
|
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"];
|
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 {
|
function isGffFile(tabKey: string): boolean {
|
||||||
return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext));
|
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 {
|
function repoFromTabKey(tabKey: string): string {
|
||||||
const idx = tabKey.indexOf(":");
|
const idx = tabKey.indexOf(":");
|
||||||
return idx > 0 ? tabKey.slice(0, idx) : "";
|
return idx > 0 ? tabKey.slice(0, idx) : "";
|
||||||
@@ -99,16 +114,28 @@ export function Editor({ editorState }: EditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isActiveGff && activeMode === "visual") {
|
if (isActiveGff && activeMode === "visual") {
|
||||||
return (
|
const editorType = getGffEditorType(activeTab);
|
||||||
<GffEditor
|
const commonProps = {
|
||||||
key={activeTab}
|
key: activeTab,
|
||||||
repo={activeRepo}
|
repo: activeRepo,
|
||||||
filePath={activeFilePath}
|
filePath: activeFilePath,
|
||||||
content={activeContent}
|
content: activeContent,
|
||||||
onSave={handleGffSave}
|
onSave: handleGffSave,
|
||||||
onSwitchToRaw={handleSwitchToRaw}
|
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 (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user