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