Compare commits
4 Commits
13caeb482b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cf4ac4d2e | |||
| 8b08d9571e | |||
| bdf5f4b44e | |||
| 676290faf7 |
@@ -36,6 +36,14 @@ PUT /remote/object/property
|
||||
|
||||
The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unreal for all readable properties exposed on that UObject.
|
||||
|
||||
Details payloads are cached in browser `localStorage` for five minutes per object path. When you reselect an object, cached properties render immediately and the app refreshes them from Unreal in the background.
|
||||
|
||||
Asset-looking property values also request `/remote/object/thumbnail` and cache thumbnail data in `localStorage`, so mesh/material fields can render closer to Unreal's Details panel.
|
||||
|
||||
Common Details values are editable from the web UI. The app writes via `/remote/object/property` using `WRITE_TRANSACTION_ACCESS`, applies optimistic row updates, then refreshes from Unreal. Selected-object properties poll roughly once per second when no edit is dirty, and the Outliner refreshes in the background to catch actor changes.
|
||||
|
||||
The Details panel also has a pinned Transform section. It reads and writes Location, Rotation, and Scale through common actor/component Remote Control function calls, falling back across actor and scene-component variants when available.
|
||||
|
||||
## Run
|
||||
|
||||
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
|
||||
|
||||
451
src/App.tsx
Normal file
451
src/App.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronRight, Eye, Layers, Mountain, PackagePlus, RefreshCw, Search, Settings, SlidersHorizontal, X } from "lucide-react";
|
||||
import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types";
|
||||
import { DetailsPanel } from "./components/DetailsPanel";
|
||||
import { OutlinerRow } from "./components/OutlinerRow";
|
||||
import { useDetailsEditing } from "./hooks/useDetailsEditing";
|
||||
import { useTransformEditing } from "./hooks/useTransformEditing";
|
||||
import { extractObjectPath, extractReturnValue } from "./lib/remoteValues";
|
||||
import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree";
|
||||
import { normalizeDetails } from "./services/details";
|
||||
import { applyCachedActor } from "./services/actorCache";
|
||||
import { readCachedDetails, writeCachedDetails } from "./services/detailsCache";
|
||||
import { discoverComponents } from "./services/components";
|
||||
import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl";
|
||||
import { createDefaultTransform, readObjectTransform } from "./services/transform";
|
||||
import { enrichActor, getAllLevelActors, normalizeActor } from "./services/unrealActors";
|
||||
import type { ObjectComponent, ObjectTransform, TransformTargetKind } from "./types";
|
||||
|
||||
const SPLIT_STORAGE_KEY = "unreal-outliner.split-ratio";
|
||||
const DEFAULT_SPLIT_RATIO = 0.52;
|
||||
const MIN_SPLIT_RATIO = 0.22;
|
||||
const MAX_SPLIT_RATIO = 0.78;
|
||||
|
||||
function readSavedSplitRatio(): number {
|
||||
const raw = window.localStorage.getItem(SPLIT_STORAGE_KEY);
|
||||
const value = raw ? Number(raw) : DEFAULT_SPLIT_RATIO;
|
||||
return Number.isFinite(value) ? Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, value)) : DEFAULT_SPLIT_RATIO;
|
||||
}
|
||||
|
||||
function mergeTransformFallback(nextTransform: ObjectTransform | null, previousTransform: ObjectTransform | null): ObjectTransform | null {
|
||||
if (!nextTransform || !previousTransform || nextTransform.objectPath !== previousTransform.objectPath) {
|
||||
return nextTransform;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextTransform,
|
||||
location: nextTransform.readStatus.location ? nextTransform.location : previousTransform.location,
|
||||
rotation: nextTransform.readStatus.rotation ? nextTransform.rotation : previousTransform.rotation,
|
||||
scale: nextTransform.readStatus.scale ? nextTransform.scale : previousTransform.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [actors, setActors] = React.useState<ActorRow[]>([]);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(new Set(["world"]));
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [status, setStatus] = React.useState("Ready");
|
||||
const [transport, setTransport] = React.useState<RemoteTransport>("HTTP");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [details, setDetails] = React.useState<ObjectDetails | null>(null);
|
||||
const [detailsError, setDetailsError] = React.useState<string | null>(null);
|
||||
const [isDetailsLoading, setIsDetailsLoading] = React.useState(false);
|
||||
const [splitRatio, setSplitRatio] = React.useState(readSavedSplitRatio);
|
||||
const [selectedObjectPath, setSelectedObjectPath] = React.useState<string | null>(null);
|
||||
const [components, setComponents] = React.useState<ObjectComponent[]>([]);
|
||||
const [transform, setTransform] = React.useState<ObjectTransform | null>(null);
|
||||
const mainContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const selectedObjectPathRef = React.useRef<string | null>(null);
|
||||
|
||||
const tree = React.useMemo(() => buildTree(actors), [actors]);
|
||||
const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]);
|
||||
const selectedTransformTargetKind = React.useCallback(
|
||||
(objectPath: string | null): TransformTargetKind => (objectPath && selectedNode?.objectPath === objectPath ? "actor" : "component"),
|
||||
[selectedNode?.objectPath],
|
||||
);
|
||||
|
||||
const enrichActorsInBackground = React.useCallback(async (rows: ActorRow[]) => {
|
||||
for (let index = 0; index < rows.length; index += 4) {
|
||||
const enrichedBatch = await Promise.all(rows.slice(index, index + 4).map(enrichActor));
|
||||
setActors((currentActors) => {
|
||||
const byId = new Map(enrichedBatch.map((actor) => [actor.id, actor]));
|
||||
return currentActors.map((actor) => byId.get(actor.id) ?? actor);
|
||||
});
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadActors = React.useCallback(async (silent = false) => {
|
||||
if (!silent) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setStatus("Loading actors...");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getAllLevelActors();
|
||||
const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) }));
|
||||
const rows = actorRefs.map(({ raw }, index) => applyCachedActor(normalizeActor(raw, index)));
|
||||
setTransport(getActiveTransport());
|
||||
setActors(rows);
|
||||
setExpanded((current) => new Set([...current, "world"]));
|
||||
if (!silent) {
|
||||
setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (!silent) {
|
||||
void enrichActorsInBackground(rows);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!silent) {
|
||||
const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control";
|
||||
setError(message);
|
||||
setStatus("Connection failed");
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [enrichActorsInBackground]);
|
||||
|
||||
const refresh = React.useCallback(() => loadActors(false), [loadActors]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadActors(false);
|
||||
}, [loadActors]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadActors(true);
|
||||
}, 4000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [loadActors]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedObjectPath(selectedNode?.objectPath ?? null);
|
||||
setComponents([]);
|
||||
setTransform(selectedNode?.objectPath ? createDefaultTransform(selectedNode.objectPath, "actor") : null);
|
||||
}, [selectedNode?.objectPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedObjectPathRef.current = selectedObjectPath;
|
||||
}, [selectedObjectPath]);
|
||||
|
||||
const loadDetails = React.useCallback(
|
||||
async (objectPath: string, useCache: boolean) => {
|
||||
if (useCache) {
|
||||
const cachedDetails = readCachedDetails(objectPath);
|
||||
if (cachedDetails && selectedObjectPathRef.current === objectPath) {
|
||||
setDetails(cachedDetails);
|
||||
setIsDetailsLoading(false);
|
||||
} else if (selectedObjectPathRef.current === objectPath) {
|
||||
setDetails(null);
|
||||
setIsDetailsLoading(true);
|
||||
}
|
||||
} else {
|
||||
setIsDetailsLoading(false);
|
||||
}
|
||||
|
||||
setDetailsError(null);
|
||||
const [description, properties] = await Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)]);
|
||||
const freshDetails = normalizeDetails(objectPath, description, properties);
|
||||
writeCachedDetails(objectPath, freshDetails);
|
||||
if (selectedObjectPathRef.current !== objectPath) {
|
||||
return freshDetails;
|
||||
}
|
||||
setDetails(freshDetails);
|
||||
setTransport(getActiveTransport());
|
||||
|
||||
if (selectedNode?.objectPath && objectPath === selectedNode.objectPath) {
|
||||
setComponents(await discoverComponents(objectPath, freshDetails.properties));
|
||||
}
|
||||
|
||||
return freshDetails;
|
||||
},
|
||||
[selectedNode?.objectPath],
|
||||
);
|
||||
|
||||
const reloadSelectedDetails = React.useCallback(async () => {
|
||||
if (!selectedObjectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadDetails(selectedObjectPath, false);
|
||||
const nextTransform = await readObjectTransform(selectedObjectPath, selectedTransformTargetKind(selectedObjectPath));
|
||||
setTransform((current) => mergeTransformFallback(nextTransform, current));
|
||||
} catch (reason) {
|
||||
setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties");
|
||||
} finally {
|
||||
setIsDetailsLoading(false);
|
||||
}
|
||||
}, [loadDetails, selectedObjectPath]);
|
||||
|
||||
const { editProperty, rowStates, hasPendingEdits, markExternalUpdate } = useDetailsEditing({
|
||||
details,
|
||||
setDetails,
|
||||
onCommitted: reloadSelectedDetails,
|
||||
});
|
||||
|
||||
const { editTransform, transformRowStates, hasPendingTransformEdits, markTransformExternalUpdate } = useTransformEditing({
|
||||
transform,
|
||||
setTransform,
|
||||
onCommitted: reloadSelectedDetails,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (!selectedObjectPath) {
|
||||
setDetails(null);
|
||||
setTransform(null);
|
||||
setDetailsError(null);
|
||||
setIsDetailsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKind = selectedTransformTargetKind(selectedObjectPath);
|
||||
setTransform((current) => (current?.objectPath === selectedObjectPath ? current : createDefaultTransform(selectedObjectPath, targetKind)));
|
||||
|
||||
const previousTransform = transform;
|
||||
Promise.all([loadDetails(selectedObjectPath, true), readObjectTransform(selectedObjectPath, targetKind)])
|
||||
.then(([, nextTransform]) => {
|
||||
if (!isCancelled) {
|
||||
setTransform((current) => mergeTransformFallback(nextTransform, current ?? previousTransform));
|
||||
}
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!isCancelled) {
|
||||
setDetails(null);
|
||||
setTransform(null);
|
||||
setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsDetailsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [loadDetails, selectedObjectPath, selectedTransformTargetKind]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedObjectPath || hasPendingEdits || hasPendingTransformEdits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
Promise.all([
|
||||
describeRemoteObject(selectedObjectPath),
|
||||
readRemoteObjectProperties(selectedObjectPath),
|
||||
readObjectTransform(selectedObjectPath, selectedTransformTargetKind(selectedObjectPath)),
|
||||
])
|
||||
.then(([description, properties, nextTransform]) => {
|
||||
const freshDetails = normalizeDetails(selectedObjectPath, description, properties);
|
||||
markExternalUpdate(freshDetails);
|
||||
if (nextTransform) {
|
||||
const mergedTransform = mergeTransformFallback(nextTransform, transform) ?? nextTransform;
|
||||
markTransformExternalUpdate(mergedTransform);
|
||||
setTransform(mergedTransform);
|
||||
}
|
||||
setDetails(freshDetails);
|
||||
writeCachedDetails(selectedObjectPath, freshDetails);
|
||||
setTransport(getActiveTransport());
|
||||
})
|
||||
.catch(() => {
|
||||
// Background refresh is best effort; foreground loads surface errors.
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasPendingEdits, hasPendingTransformEdits, markExternalUpdate, markTransformExternalUpdate, selectedObjectPath, selectedTransformTargetKind, transform]);
|
||||
|
||||
const toggleNode = (node: TreeNode) => {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(node.id)) {
|
||||
next.delete(node.id);
|
||||
} else {
|
||||
next.add(node.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setClampedSplitRatio = React.useCallback((nextRatio: number) => {
|
||||
const clamped = Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, nextRatio));
|
||||
setSplitRatio(clamped);
|
||||
window.localStorage.setItem(SPLIT_STORAGE_KEY, String(clamped));
|
||||
}, []);
|
||||
|
||||
const startResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const container = mainContentRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = event.pointerId;
|
||||
event.currentTarget.setPointerCapture(pointerId);
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const bounds = container.getBoundingClientRect();
|
||||
const nextRatio = (moveEvent.clientY - bounds.top) / bounds.height;
|
||||
setClampedSplitRatio(nextRatio);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointercancel", onPointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointercancel", onPointerUp);
|
||||
};
|
||||
|
||||
const resizeWithKeyboard = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setClampedSplitRatio(splitRatio - 0.04);
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setClampedSplitRatio(splitRatio + 0.04);
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setClampedSplitRatio(MIN_SPLIT_RATIO);
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setClampedSplitRatio(MAX_SPLIT_RATIO);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="panel" aria-label="Unreal Outliner">
|
||||
<header className="tabs">
|
||||
<div className="tab active">
|
||||
<SlidersHorizontal size={24} />
|
||||
<span>Outliner</span>
|
||||
<X size={18} />
|
||||
</div>
|
||||
<div className="tab">
|
||||
<Mountain size={23} />
|
||||
<span>Levels</span>
|
||||
</div>
|
||||
<div className="tab">
|
||||
<Layers size={23} />
|
||||
<span>Layers</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<button className="icon-button" type="button" title="View options">
|
||||
<SlidersHorizontal size={22} />
|
||||
</button>
|
||||
<button className="icon-button" type="button" title="Collapse or expand root" onClick={() => toggleNode(tree)}>
|
||||
{expanded.has("world") ? <ChevronDown size={21} /> : <ChevronRight size={21} />}
|
||||
</button>
|
||||
<label className="search">
|
||||
<Search size={22} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search..." />
|
||||
</label>
|
||||
<button className="icon-button" type="button" title="Refresh actors" onClick={refresh} disabled={isLoading}>
|
||||
<RefreshCw size={21} className={isLoading ? "spin" : ""} />
|
||||
</button>
|
||||
<button className="icon-button" type="button" title="Create folder">
|
||||
<PackagePlus size={21} />
|
||||
</button>
|
||||
<button className="icon-button" type="button" title="Settings">
|
||||
<Settings size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="columns" role="row">
|
||||
<span className="visibility">
|
||||
<Eye size={20} />
|
||||
</span>
|
||||
<span>Item Label</span>
|
||||
<span>Type</span>
|
||||
<span>Level</span>
|
||||
<span>ID Name</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="main-content"
|
||||
ref={mainContentRef}
|
||||
style={{ "--outliner-ratio": splitRatio, "--details-ratio": 1 - splitRatio } as React.CSSProperties}
|
||||
>
|
||||
<div className="rows" role="tree">
|
||||
{error ? (
|
||||
<div className="empty">
|
||||
<strong>{status}</strong>
|
||||
<span>{error}</span>
|
||||
<span>Remote Control is reachable, but Unreal rejected the object call. Check the exact error above.</span>
|
||||
</div>
|
||||
) : (
|
||||
<OutlinerRow
|
||||
node={tree}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
selectedId={selectedId}
|
||||
query={query}
|
||||
onToggle={toggleNode}
|
||||
onSelect={(node) => setSelectedId(node.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label="Resize Outliner and Details panes"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemax={Math.round(MAX_SPLIT_RATIO * 100)}
|
||||
aria-valuemin={Math.round(MIN_SPLIT_RATIO * 100)}
|
||||
aria-valuenow={Math.round(splitRatio * 100)}
|
||||
className="pane-splitter"
|
||||
onDoubleClick={() => setClampedSplitRatio(DEFAULT_SPLIT_RATIO)}
|
||||
onKeyDown={resizeWithKeyboard}
|
||||
onPointerDown={startResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="Drag to resize panes. Double-click to reset."
|
||||
>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<DetailsPanel
|
||||
node={selectedNode}
|
||||
details={details}
|
||||
components={components}
|
||||
selectedObjectPath={selectedObjectPath}
|
||||
transform={transform}
|
||||
transformRowStates={transformRowStates}
|
||||
isLoading={isDetailsLoading}
|
||||
error={detailsError}
|
||||
rowStates={rowStates}
|
||||
onEditProperty={editProperty}
|
||||
onEditTransform={editTransform}
|
||||
onSelectObjectPath={setSelectedObjectPath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="footer">
|
||||
<span>{query ? `${visibleCount(tree)} actors total` : `${actors.length} actor${actors.length === 1 ? "" : "s"}`}</span>
|
||||
<span>
|
||||
{status} - {transport}
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
532
src/components/DetailsPanel.tsx
Normal file
532
src/components/DetailsPanel.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import React from "react";
|
||||
import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react";
|
||||
import type {
|
||||
DetailProperty,
|
||||
DetailRowStatus,
|
||||
ObjectComponent,
|
||||
ObjectDetails,
|
||||
ObjectTransform,
|
||||
TransformField,
|
||||
TransformVector,
|
||||
TreeNode,
|
||||
} from "../types";
|
||||
import { basename } from "../lib/remoteValues";
|
||||
import {
|
||||
detailAssetPath,
|
||||
formatDetailValue,
|
||||
isBooleanProperty,
|
||||
isVectorLikeProperty,
|
||||
normalizeThumbnailPayload,
|
||||
vectorParts,
|
||||
withUpdatedVectorPart,
|
||||
} from "../services/details";
|
||||
import { readRemoteObjectThumbnail } from "../services/remoteControl";
|
||||
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
|
||||
import { detailPropertyKey } from "../hooks/useDetailsEditing";
|
||||
import { NodeIcon } from "./NodeIcon";
|
||||
|
||||
const emptyNode: TreeNode = {
|
||||
id: "empty",
|
||||
label: "No Selection",
|
||||
type: "Object",
|
||||
level: "",
|
||||
idName: "",
|
||||
kind: "actor",
|
||||
children: [],
|
||||
};
|
||||
|
||||
function NumberInput({
|
||||
property,
|
||||
status,
|
||||
onEdit,
|
||||
}: {
|
||||
property: DetailProperty;
|
||||
status: DetailRowStatus;
|
||||
onEdit: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState(formatDetailValue(property.value));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== "dirty") {
|
||||
setDraft(formatDetailValue(property.value));
|
||||
}
|
||||
}, [property.value, status]);
|
||||
|
||||
const commit = () => {
|
||||
if (Number.isFinite(Number(draft))) {
|
||||
onEdit(property, draft, true);
|
||||
} else {
|
||||
setDraft(formatDetailValue(property.value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
className="text-value"
|
||||
disabled={!property.editable || status === "saving"}
|
||||
inputMode="decimal"
|
||||
onBlur={commit}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
if (Number.isFinite(Number(event.target.value))) {
|
||||
onEdit(property, event.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
value={draft}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VectorInput({
|
||||
property,
|
||||
status,
|
||||
onEdit,
|
||||
}: {
|
||||
property: DetailProperty;
|
||||
status: DetailRowStatus;
|
||||
onEdit: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
|
||||
}) {
|
||||
const parts = vectorParts(property.value);
|
||||
const [drafts, setDrafts] = React.useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(parts.map((part) => [part.label, part.value])),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== "dirty") {
|
||||
setDrafts(Object.fromEntries(vectorParts(property.value).map((part) => [part.label, part.value])));
|
||||
}
|
||||
}, [property.value, status]);
|
||||
|
||||
const commit = (label: string) => {
|
||||
const draft = drafts[label];
|
||||
if (Number.isFinite(Number(draft))) {
|
||||
onEdit(property, withUpdatedVectorPart(property, label, draft), true);
|
||||
} else {
|
||||
setDrafts(Object.fromEntries(vectorParts(property.value).map((part) => [part.label, part.value])));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vector-value">
|
||||
{parts.map((part, index) => (
|
||||
<input
|
||||
className={`axis axis-${index}`}
|
||||
disabled={!property.editable || status === "saving"}
|
||||
inputMode="decimal"
|
||||
key={part.label}
|
||||
onBlur={() => commit(part.label)}
|
||||
onChange={(event) => {
|
||||
const nextDraft = event.target.value;
|
||||
setDrafts((current) => ({ ...current, [part.label]: nextDraft }));
|
||||
if (Number.isFinite(Number(nextDraft))) {
|
||||
onEdit(property, withUpdatedVectorPart(property, part.label, nextDraft));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
commit(part.label);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
value={drafts[part.label] ?? part.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function transformParts(value: TransformVector): Array<{ label: "x" | "y" | "z"; value: string }> {
|
||||
return [
|
||||
{ label: "x", value: formatDetailValue(value.x) },
|
||||
{ label: "y", value: formatDetailValue(value.y) },
|
||||
{ label: "z", value: formatDetailValue(value.z) },
|
||||
];
|
||||
}
|
||||
|
||||
function TransformInput({
|
||||
field,
|
||||
value,
|
||||
status,
|
||||
onEdit,
|
||||
}: {
|
||||
field: TransformField;
|
||||
value: TransformVector;
|
||||
status: DetailRowStatus;
|
||||
onEdit: (field: TransformField, value: TransformVector, commitImmediately?: boolean) => void;
|
||||
}) {
|
||||
const parts = transformParts(value);
|
||||
const focusedAxisRef = React.useRef<"x" | "y" | "z" | null>(null);
|
||||
const [drafts, setDrafts] = React.useState<Record<"x" | "y" | "z", string>>({
|
||||
x: parts[0].value,
|
||||
y: parts[1].value,
|
||||
z: parts[2].value,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== "dirty" && focusedAxisRef.current === null) {
|
||||
const nextParts = transformParts(value);
|
||||
setDrafts({ x: nextParts[0].value, y: nextParts[1].value, z: nextParts[2].value });
|
||||
}
|
||||
}, [status, value]);
|
||||
|
||||
const nextVector = (axis: "x" | "y" | "z", draft: string): TransformVector | null => {
|
||||
const numericValue = Number(draft);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
[axis]: numericValue,
|
||||
};
|
||||
};
|
||||
|
||||
const commit = (axis: "x" | "y" | "z") => {
|
||||
focusedAxisRef.current = null;
|
||||
const next = nextVector(axis, drafts[axis]);
|
||||
if (next) {
|
||||
onEdit(field, next, true);
|
||||
} else {
|
||||
const nextParts = transformParts(value);
|
||||
setDrafts({ x: nextParts[0].value, y: nextParts[1].value, z: nextParts[2].value });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vector-value transform-vector">
|
||||
{parts.map((part, index) => (
|
||||
<input
|
||||
className={`axis axis-${index}`}
|
||||
inputMode="decimal"
|
||||
key={part.label}
|
||||
onFocus={() => {
|
||||
focusedAxisRef.current = part.label;
|
||||
}}
|
||||
onBlur={() => commit(part.label)}
|
||||
onChange={(event) => {
|
||||
const draft = event.target.value;
|
||||
setDrafts((current) => ({ ...current, [part.label]: draft }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
commit(part.label);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
value={drafts[part.label]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransformSection({
|
||||
transform,
|
||||
rowStates,
|
||||
onEdit,
|
||||
}: {
|
||||
transform: ObjectTransform | null;
|
||||
rowStates: Record<TransformField, { status: DetailRowStatus; error?: string }>;
|
||||
onEdit: (field: TransformField, value: TransformVector, commitImmediately?: boolean) => void;
|
||||
}) {
|
||||
if (!transform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rows: Array<{ field: TransformField; label: string }> = [
|
||||
{ field: "location", label: "Location" },
|
||||
{ field: "rotation", label: "Rotation" },
|
||||
{ field: "scale", label: "Scale" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="detail-category transform-section">
|
||||
<h3>
|
||||
<ChevronDown size={16} />
|
||||
Transform
|
||||
</h3>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
className={`detail-row ${rowStates[row.field]?.status ?? "clean"}`}
|
||||
key={row.field}
|
||||
title={rowStates[row.field]?.error || row.label}
|
||||
>
|
||||
<span className="detail-name">{row.label}</span>
|
||||
<span className="detail-value">
|
||||
<TransformInput field={row.field} value={transform[row.field]} status={rowStates[row.field]?.status ?? "clean"} onEdit={onEdit} />
|
||||
<button className="reset-button row-reset" type="button" title="Reset to default">
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailValue({
|
||||
property,
|
||||
status,
|
||||
onEdit,
|
||||
}: {
|
||||
property: DetailProperty;
|
||||
status: DetailRowStatus;
|
||||
onEdit: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
|
||||
}) {
|
||||
const assetPath = detailAssetPath(property);
|
||||
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (!assetPath) {
|
||||
setThumbnail("");
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readCachedThumbnail(assetPath);
|
||||
if (cached) {
|
||||
setThumbnail(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
readRemoteObjectThumbnail(assetPath)
|
||||
.then((payload) => {
|
||||
const dataUrl = normalizeThumbnailPayload(payload);
|
||||
if (!isCancelled && dataUrl) {
|
||||
setThumbnail(dataUrl);
|
||||
writeCachedThumbnail(assetPath, dataUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setThumbnail("");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [assetPath]);
|
||||
|
||||
if (assetPath) {
|
||||
return (
|
||||
<div className="asset-value">
|
||||
<div className="asset-thumb">{thumbnail ? <img src={thumbnail} alt="" /> : <Box size={28} />}</div>
|
||||
<div className="asset-control">
|
||||
<span>{formatDetailValue(property.value)}</span>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVectorLikeProperty(property)) {
|
||||
return <VectorInput property={property} status={status} onEdit={onEdit} />;
|
||||
}
|
||||
|
||||
if (isBooleanProperty(property)) {
|
||||
return (
|
||||
<button
|
||||
className={`checkbox-value ${property.value ? "checked" : ""}`}
|
||||
disabled={!property.editable || status === "saving"}
|
||||
onClick={() => onEdit(property, !property.value, true)}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (property.controlKind === "number") {
|
||||
return <NumberInput property={property} status={status} onEdit={onEdit} />;
|
||||
}
|
||||
|
||||
if (property.controlKind === "string") {
|
||||
return (
|
||||
<input
|
||||
className="text-value"
|
||||
disabled={!property.editable || status === "saving"}
|
||||
onBlur={(event) => onEdit(property, event.target.value, true)}
|
||||
onChange={(event) => onEdit(property, event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
onEdit(property, event.currentTarget.value, true);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
value={String(property.value ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-value ${property.editable ? "" : "readonly-value"}`}>
|
||||
<span>{formatDetailValue(property.value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({
|
||||
node,
|
||||
details,
|
||||
components,
|
||||
selectedObjectPath,
|
||||
transform,
|
||||
transformRowStates,
|
||||
isLoading,
|
||||
error,
|
||||
rowStates,
|
||||
onEditProperty,
|
||||
onEditTransform,
|
||||
onSelectObjectPath,
|
||||
}: {
|
||||
node: TreeNode | null;
|
||||
details: ObjectDetails | null;
|
||||
components: ObjectComponent[];
|
||||
selectedObjectPath: string | null;
|
||||
transform: ObjectTransform | null;
|
||||
transformRowStates: Record<TransformField, { status: DetailRowStatus; error?: string }>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
rowStates: Record<string, { status: DetailRowStatus; error?: string }>;
|
||||
onEditProperty: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
|
||||
onEditTransform: (field: TransformField, value: TransformVector, commitImmediately?: boolean) => void;
|
||||
onSelectObjectPath: (objectPath: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const groupedProperties = React.useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const visibleProperties = (details?.properties ?? []).filter((property) => {
|
||||
if (!lowerQuery) return true;
|
||||
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
|
||||
field.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
});
|
||||
|
||||
return visibleProperties.reduce<Record<string, DetailProperty[]>>((groups, property) => {
|
||||
const category = property.category || "General";
|
||||
groups[category] = groups[category] ?? [];
|
||||
groups[category].push(property);
|
||||
return groups;
|
||||
}, {});
|
||||
}, [details, query]);
|
||||
|
||||
return (
|
||||
<aside className="details-panel" aria-label="Details">
|
||||
<header className="details-tabs">
|
||||
<div className="details-tab muted">
|
||||
<Settings size={18} />
|
||||
<span>World Settings</span>
|
||||
</div>
|
||||
<div className="details-tab active">
|
||||
<SlidersHorizontal size={18} />
|
||||
<span>Details</span>
|
||||
<X size={15} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="details-object">
|
||||
<NodeIcon node={node ?? emptyNode} />
|
||||
<div>
|
||||
<strong>{node?.label ?? "No Selection"}</strong>
|
||||
<span>{details?.className ? basename(details.className) : node?.type ?? "Select an actor in the Outliner"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{node?.objectPath ? (
|
||||
<div className="component-list">
|
||||
<button
|
||||
className={`component-row ${selectedObjectPath === node.objectPath ? "selected" : ""}`}
|
||||
onClick={() => onSelectObjectPath(node.objectPath!)}
|
||||
type="button"
|
||||
>
|
||||
<NodeIcon node={node ?? emptyNode} />
|
||||
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
|
||||
</button>
|
||||
{components.map((component) => (
|
||||
<button
|
||||
className={`component-row child ${selectedObjectPath === component.objectPath ? "selected" : ""}`}
|
||||
key={component.id}
|
||||
onClick={() => onSelectObjectPath(component.objectPath)}
|
||||
type="button"
|
||||
>
|
||||
<Box size={18} />
|
||||
<span>{component.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="details-search-row">
|
||||
<label className="details-search">
|
||||
<Search size={18} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search" />
|
||||
</label>
|
||||
<button className="details-tool" type="button" title="View Options">
|
||||
<Grid2X2 size={18} />
|
||||
</button>
|
||||
<button className="details-tool" type="button" title="Favorites">
|
||||
<Star size={18} />
|
||||
</button>
|
||||
<button className="details-tool" type="button" title="Settings">
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="details-categories">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
|
||||
<div className="details-empty">Loading properties...</div>
|
||||
</>
|
||||
) : error ? (
|
||||
<div className="details-empty">{error}</div>
|
||||
) : !node?.objectPath ? (
|
||||
<div className="details-empty">Select an actor to inspect exposed properties.</div>
|
||||
) : Object.keys(groupedProperties).length === 0 ? (
|
||||
<>
|
||||
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
|
||||
{!transform ? <div className="details-empty">No readable properties found.</div> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
|
||||
{Object.entries(groupedProperties).map(([category, properties]) => (
|
||||
<section className="detail-category" key={category}>
|
||||
<h3>
|
||||
<ChevronDown size={16} />
|
||||
{category}
|
||||
</h3>
|
||||
{properties.map((property) => (
|
||||
<div
|
||||
className={`detail-row ${rowStates[detailPropertyKey(property)]?.status ?? "clean"} ${property.editable ? "" : "read-only"}`}
|
||||
key={property.name}
|
||||
title={rowStates[detailPropertyKey(property)]?.error || property.description || property.type}
|
||||
>
|
||||
<span className="detail-name">{property.displayName}</span>
|
||||
<span className="detail-value">
|
||||
<DetailValue
|
||||
property={property}
|
||||
status={rowStates[detailPropertyKey(property)]?.status ?? "clean"}
|
||||
onEdit={onEditProperty}
|
||||
/>
|
||||
<button className="reset-button row-reset" type="button" title="Reset to default">
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
23
src/components/NodeIcon.tsx
Normal file
23
src/components/NodeIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Box, Cloud, Folder, Gamepad2, Mountain, Sun, Triangle } from "lucide-react";
|
||||
import type { TreeNode } from "../types";
|
||||
|
||||
function actorIcon(type: string) {
|
||||
const lower = type.toLowerCase();
|
||||
if (lower.includes("directional") || lower.includes("light")) return Sun;
|
||||
if (lower.includes("fog")) return Cloud;
|
||||
if (lower.includes("sky") || lower.includes("atmosphere")) return Mountain;
|
||||
if (lower.includes("start") || lower.includes("pawn")) return Gamepad2;
|
||||
if (lower.includes("mesh")) return Box;
|
||||
return Triangle;
|
||||
}
|
||||
|
||||
export function NodeIcon({ node }: { node: TreeNode }) {
|
||||
if (node.kind === "folder") {
|
||||
return <Folder className="folder-icon" size={20} />;
|
||||
}
|
||||
if (node.kind === "world") {
|
||||
return <Mountain className="world-icon" size={19} />;
|
||||
}
|
||||
const Icon = actorIcon(node.type);
|
||||
return <Icon className="actor-icon" size={19} />;
|
||||
}
|
||||
77
src/components/OutlinerRow.tsx
Normal file
77
src/components/OutlinerRow.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type React from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { TreeNode } from "../types";
|
||||
import { nodeMatchesQuery } from "../models/outlinerTree";
|
||||
import { NodeIcon } from "./NodeIcon";
|
||||
|
||||
export function OutlinerRow({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
selectedId,
|
||||
query,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
selectedId: string | null;
|
||||
query: string;
|
||||
onToggle: (node: TreeNode) => void;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isOpen = expanded.has(node.id);
|
||||
const matches =
|
||||
!query ||
|
||||
[node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(query.toLowerCase()));
|
||||
const visibleChildren = node.children.filter((child) => nodeMatchesQuery(child, query));
|
||||
|
||||
if (!matches && visibleChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`outliner-row ${selectedId === node.id ? "selected" : ""}`}
|
||||
style={{ "--depth": depth } as React.CSSProperties}
|
||||
onClick={() => onSelect(node)}
|
||||
onDoubleClick={() => hasChildren && onToggle(node)}
|
||||
title={node.objectPath || node.label}
|
||||
>
|
||||
<span className="cell item-cell">
|
||||
<span className="indent" />
|
||||
<span
|
||||
className="expander"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(node);
|
||||
}}
|
||||
>
|
||||
{hasChildren ? isOpen ? <ChevronDown size={17} /> : <ChevronRight size={17} /> : null}
|
||||
</span>
|
||||
<NodeIcon node={node} />
|
||||
<span className="label">{node.label}</span>
|
||||
</span>
|
||||
<span className="cell type-cell">{node.type}</span>
|
||||
<span className="cell level-cell">{node.level}</span>
|
||||
<span className="cell id-cell">{node.idName}</span>
|
||||
</button>
|
||||
{(isOpen || query) &&
|
||||
visibleChildren.map((child) => (
|
||||
<OutlinerRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
selectedId={selectedId}
|
||||
query={query}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
src/hooks/useDetailsEditing.ts
Normal file
160
src/hooks/useDetailsEditing.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import type { DetailProperty, DetailRowStatus, ObjectDetails } from "../types";
|
||||
import { coerceDetailInputValue } from "../services/details";
|
||||
import { invalidateCachedDetails, writeCachedDetails } from "../services/detailsCache";
|
||||
import { writeRemoteObjectProperty } from "../services/remoteControl";
|
||||
|
||||
type RowState = {
|
||||
status: DetailRowStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function propertyKey(property: DetailProperty): string {
|
||||
return `${property.sourceObjectPath}:${property.propertyPath}`;
|
||||
}
|
||||
|
||||
function updateDetailsProperty(details: ObjectDetails | null, key: string, value: unknown): ObjectDetails | null {
|
||||
if (!details) {
|
||||
return details;
|
||||
}
|
||||
|
||||
return {
|
||||
...details,
|
||||
properties: details.properties.map((property) => {
|
||||
if (propertyKey(property) !== key) {
|
||||
return property;
|
||||
}
|
||||
|
||||
return {
|
||||
...property,
|
||||
value,
|
||||
writeValue: value,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDetailsEditing({
|
||||
details,
|
||||
setDetails,
|
||||
onCommitted,
|
||||
}: {
|
||||
details: ObjectDetails | null;
|
||||
setDetails: React.Dispatch<React.SetStateAction<ObjectDetails | null>>;
|
||||
onCommitted: () => Promise<void> | void;
|
||||
}) {
|
||||
const [rowStates, setRowStates] = React.useState<Record<string, RowState>>({});
|
||||
const debounceTimers = React.useRef<Record<string, number>>({});
|
||||
const latestDetails = React.useRef<ObjectDetails | null>(details);
|
||||
|
||||
React.useEffect(() => {
|
||||
latestDetails.current = details;
|
||||
}, [details]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
Object.values(debounceTimers.current).forEach((timerId) => window.clearTimeout(timerId));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasPendingEdits = React.useMemo(
|
||||
() => Object.values(rowStates).some((rowState) => rowState.status === "dirty" || rowState.status === "saving"),
|
||||
[rowStates],
|
||||
);
|
||||
|
||||
const setRowState = React.useCallback((key: string, state: RowState) => {
|
||||
setRowStates((current) => ({ ...current, [key]: state }));
|
||||
}, []);
|
||||
|
||||
const commitProperty = React.useCallback(
|
||||
async (property: DetailProperty, value: unknown) => {
|
||||
const key = propertyKey(property);
|
||||
if (!property.editable || !property.sourceObjectPath || !property.propertyPath) {
|
||||
setRowState(key, { status: "error", error: "Property is read-only" });
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(debounceTimers.current[key]);
|
||||
setRowState(key, { status: "saving" });
|
||||
|
||||
try {
|
||||
await writeRemoteObjectProperty(property.sourceObjectPath, property.propertyPath, value);
|
||||
invalidateCachedDetails(property.sourceObjectPath);
|
||||
const nextDetails = updateDetailsProperty(latestDetails.current, key, value);
|
||||
if (nextDetails) {
|
||||
writeCachedDetails(property.sourceObjectPath, nextDetails);
|
||||
}
|
||||
setRowState(key, { status: "clean" });
|
||||
await onCommitted();
|
||||
} catch (reason) {
|
||||
setRowState(key, {
|
||||
status: "error",
|
||||
error: reason instanceof Error ? reason.message : "Could not write property",
|
||||
});
|
||||
await onCommitted();
|
||||
}
|
||||
},
|
||||
[onCommitted, setRowState],
|
||||
);
|
||||
|
||||
const editProperty = React.useCallback(
|
||||
(property: DetailProperty, value: unknown, commitImmediately = false) => {
|
||||
const key = propertyKey(property);
|
||||
const coercedValue = coerceDetailInputValue(property, value);
|
||||
setDetails((current) => updateDetailsProperty(current, key, coercedValue));
|
||||
setRowState(key, { status: "dirty" });
|
||||
|
||||
window.clearTimeout(debounceTimers.current[key]);
|
||||
if (commitImmediately) {
|
||||
void commitProperty(property, coercedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimers.current[key] = window.setTimeout(() => {
|
||||
void commitProperty(property, coercedValue);
|
||||
}, 250);
|
||||
},
|
||||
[commitProperty, setDetails, setRowState],
|
||||
);
|
||||
|
||||
const markExternalUpdate = React.useCallback((updatedDetails: ObjectDetails) => {
|
||||
const current = latestDetails.current;
|
||||
if (!current || current.objectPath !== updatedDetails.objectPath || hasPendingEdits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedKeys = new Set<string>();
|
||||
for (const updatedProperty of updatedDetails.properties) {
|
||||
const currentProperty = current.properties.find((property) => property.propertyPath === updatedProperty.propertyPath);
|
||||
if (currentProperty && JSON.stringify(currentProperty.value) !== JSON.stringify(updatedProperty.value)) {
|
||||
changedKeys.add(propertyKey(updatedProperty));
|
||||
}
|
||||
}
|
||||
|
||||
if (changedKeys.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRowStates((currentStates) => {
|
||||
const nextStates = { ...currentStates };
|
||||
changedKeys.forEach((key) => {
|
||||
nextStates[key] = { status: "external" };
|
||||
window.setTimeout(() => {
|
||||
setRowStates((laterStates) => (laterStates[key]?.status === "external" ? { ...laterStates, [key]: { status: "clean" } } : laterStates));
|
||||
}, 1200);
|
||||
});
|
||||
return nextStates;
|
||||
});
|
||||
}, [hasPendingEdits]);
|
||||
|
||||
return {
|
||||
editProperty,
|
||||
rowStates,
|
||||
hasPendingEdits,
|
||||
markExternalUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
export function detailPropertyKey(property: DetailProperty): string {
|
||||
return propertyKey(property);
|
||||
}
|
||||
122
src/hooks/useTransformEditing.ts
Normal file
122
src/hooks/useTransformEditing.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import type { DetailRowStatus, ObjectTransform, TransformField, TransformVector } from "../types";
|
||||
import { writeObjectTransformField } from "../services/transform";
|
||||
|
||||
type TransformRowState = Record<TransformField, { status: DetailRowStatus; error?: string }>;
|
||||
|
||||
const cleanStates: TransformRowState = {
|
||||
location: { status: "clean" },
|
||||
rotation: { status: "clean" },
|
||||
scale: { status: "clean" },
|
||||
};
|
||||
|
||||
export function useTransformEditing({
|
||||
transform,
|
||||
setTransform,
|
||||
onCommitted,
|
||||
}: {
|
||||
transform: ObjectTransform | null;
|
||||
setTransform: React.Dispatch<React.SetStateAction<ObjectTransform | null>>;
|
||||
onCommitted: () => Promise<void> | void;
|
||||
}) {
|
||||
const [rowStates, setRowStates] = React.useState<TransformRowState>(cleanStates);
|
||||
const timers = React.useRef<Partial<Record<TransformField, number>>>({});
|
||||
const latestTransform = React.useRef<ObjectTransform | null>(transform);
|
||||
|
||||
React.useEffect(() => {
|
||||
latestTransform.current = transform;
|
||||
}, [transform]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
Object.values(timers.current).forEach((timerId) => window.clearTimeout(timerId));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasPendingTransformEdits = React.useMemo(
|
||||
() => Object.values(rowStates).some((rowState) => rowState.status === "dirty" || rowState.status === "saving"),
|
||||
[rowStates],
|
||||
);
|
||||
|
||||
const setFieldState = React.useCallback((field: TransformField, status: DetailRowStatus, error?: string) => {
|
||||
setRowStates((current) => ({ ...current, [field]: { status, error } }));
|
||||
}, []);
|
||||
|
||||
const commitTransform = React.useCallback(
|
||||
async (field: TransformField, value: TransformVector) => {
|
||||
const current = latestTransform.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(timers.current[field]);
|
||||
setFieldState(field, "saving");
|
||||
|
||||
try {
|
||||
await writeObjectTransformField(current.objectPath, current.targetKind, field, value);
|
||||
setTransform((latest) =>
|
||||
latest?.objectPath === current.objectPath
|
||||
? {
|
||||
...latest,
|
||||
[field]: value,
|
||||
readStatus: {
|
||||
...latest.readStatus,
|
||||
[field]: true,
|
||||
},
|
||||
}
|
||||
: latest,
|
||||
);
|
||||
setFieldState(field, "clean");
|
||||
await onCommitted();
|
||||
} catch (reason) {
|
||||
setFieldState(field, "error", reason instanceof Error ? reason.message : "Could not write transform");
|
||||
await onCommitted();
|
||||
}
|
||||
},
|
||||
[onCommitted, setFieldState],
|
||||
);
|
||||
|
||||
const editTransform = React.useCallback(
|
||||
(field: TransformField, value: TransformVector, commitImmediately = false) => {
|
||||
setTransform((current) => (current ? { ...current, [field]: value } : current));
|
||||
setFieldState(field, "dirty");
|
||||
|
||||
window.clearTimeout(timers.current[field]);
|
||||
if (commitImmediately) {
|
||||
void commitTransform(field, value);
|
||||
return;
|
||||
}
|
||||
|
||||
timers.current[field] = window.setTimeout(() => {
|
||||
void commitTransform(field, value);
|
||||
}, 250);
|
||||
},
|
||||
[commitTransform, setFieldState, setTransform],
|
||||
);
|
||||
|
||||
const markTransformExternalUpdate = React.useCallback(
|
||||
(nextTransform: ObjectTransform) => {
|
||||
const current = latestTransform.current;
|
||||
if (!current || current.objectPath !== nextTransform.objectPath || hasPendingTransformEdits) {
|
||||
return;
|
||||
}
|
||||
|
||||
(["location", "rotation", "scale"] as TransformField[]).forEach((field) => {
|
||||
if (JSON.stringify(current[field]) !== JSON.stringify(nextTransform[field])) {
|
||||
setFieldState(field, "external");
|
||||
window.setTimeout(() => {
|
||||
setRowStates((states) => (states[field]?.status === "external" ? { ...states, [field]: { status: "clean" } } : states));
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
},
|
||||
[hasPendingTransformEdits, setFieldState],
|
||||
);
|
||||
|
||||
return {
|
||||
editTransform,
|
||||
transformRowStates: rowStates,
|
||||
hasPendingTransformEdits,
|
||||
markTransformExternalUpdate,
|
||||
};
|
||||
}
|
||||
151
src/lib/remoteValues.ts
Normal file
151
src/lib/remoteValues.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { RemoteObject } from "../types";
|
||||
|
||||
export function asRecord(value: unknown): RemoteObject {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as RemoteObject) : {};
|
||||
}
|
||||
|
||||
export function isObjectRecord(value: unknown): value is RemoteObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function firstString(source: RemoteObject, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function findStringDeep(value: unknown, keys: string[], depth = 0): string {
|
||||
if (depth > 3) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!isObjectRecord(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const direct = value[key];
|
||||
if (typeof direct === "string" && direct.trim()) {
|
||||
return direct;
|
||||
}
|
||||
if (isObjectRecord(direct)) {
|
||||
const nested = findStringDeep(direct, keys, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(value)) {
|
||||
if (Array.isArray(nestedValue)) {
|
||||
continue;
|
||||
}
|
||||
const nested = findStringDeep(nestedValue, keys, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractObjectPath(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return findStringDeep(value, [
|
||||
"objectPath",
|
||||
"ObjectPath",
|
||||
"path",
|
||||
"Path",
|
||||
"actorPath",
|
||||
"ActorPath",
|
||||
"softObjectPath",
|
||||
"SoftObjectPath",
|
||||
"name",
|
||||
"Name",
|
||||
]);
|
||||
}
|
||||
|
||||
export function nestedString(source: RemoteObject, keys: string[], fallback = ""): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
const nested = asRecord(value);
|
||||
const nestedValue = firstString(nested, ["Name", "name", "Path", "path", "ObjectPath", "objectPath"]);
|
||||
if (nestedValue) {
|
||||
return nestedValue;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
const clean = path.replace(/["']/g, "");
|
||||
const colonName = clean.split(":").pop() ?? clean;
|
||||
const dotName = colonName.split(".").pop() ?? colonName;
|
||||
const slashName = dotName.split("/").pop() ?? dotName;
|
||||
return slashName || clean || "Actor";
|
||||
}
|
||||
|
||||
export function cleanObjectName(name: string): string {
|
||||
return name
|
||||
.replace(/^Default__/, "")
|
||||
.replace(/^UEDPIE_\d+_/, "")
|
||||
.replace(/_\d+$/, (suffix) => (suffix === "_0" ? "_0" : suffix));
|
||||
}
|
||||
|
||||
export function deriveLevelNameFromPath(path: string): string {
|
||||
if (!path) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const clean = path.replace(/["']/g, "");
|
||||
const packagePath = clean.includes(":") ? clean.split(":")[0] : clean;
|
||||
const objectName = packagePath.split(".").pop() ?? packagePath;
|
||||
return cleanObjectName(basename(objectName));
|
||||
}
|
||||
|
||||
export function extractReturnValue(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const record = asRecord(payload);
|
||||
const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result;
|
||||
if (Array.isArray(returnValue)) {
|
||||
return returnValue;
|
||||
}
|
||||
const nested = asRecord(returnValue);
|
||||
for (const value of Object.values(nested)) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function extractFirstReturnString(payload: unknown): string {
|
||||
const record = asRecord(payload);
|
||||
const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result;
|
||||
if (typeof returnValue === "string") {
|
||||
return returnValue;
|
||||
}
|
||||
if (typeof returnValue === "number") {
|
||||
return String(returnValue);
|
||||
}
|
||||
return firstString(asRecord(returnValue), ["Name", "name", "Label", "label", "DisplayName", "displayName"]);
|
||||
}
|
||||
1132
src/main.tsx
1132
src/main.tsx
File diff suppressed because it is too large
Load Diff
92
src/models/outlinerTree.ts
Normal file
92
src/models/outlinerTree.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ActorRow, TreeNode } from "../types";
|
||||
|
||||
function folderSegments(path: string): string[] {
|
||||
return path
|
||||
.split(/[\\/|]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part.toLowerCase() !== "none");
|
||||
}
|
||||
|
||||
function getOrCreateFolder(parent: TreeNode, label: string, level: string): TreeNode {
|
||||
const existing = parent.children.find((child) => child.kind === "folder" && child.label === label);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const folder: TreeNode = {
|
||||
id: `${parent.id}/folder/${label}`,
|
||||
label,
|
||||
type: "Folder",
|
||||
level,
|
||||
idName: "",
|
||||
kind: "folder",
|
||||
children: [],
|
||||
};
|
||||
parent.children.push(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
export function buildTree(actors: ActorRow[]): TreeNode {
|
||||
const rootLevelName = actors.find((actor) => actor.level && actor.level !== "Persistent Level")?.level;
|
||||
const world: TreeNode = {
|
||||
id: "world",
|
||||
label: `${rootLevelName || "Open World"} (Editor)`,
|
||||
type: "World",
|
||||
level: "",
|
||||
idName: "",
|
||||
kind: "world",
|
||||
children: [],
|
||||
};
|
||||
|
||||
for (const actor of actors) {
|
||||
let parent = world;
|
||||
for (const segment of folderSegments(actor.folderPath)) {
|
||||
parent = getOrCreateFolder(parent, segment, actor.level);
|
||||
}
|
||||
parent.children.push({
|
||||
...actor,
|
||||
kind: "actor",
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
const sortNode = (node: TreeNode) => {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === "folder" ? -1 : 1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
node.children.forEach(sortNode);
|
||||
};
|
||||
sortNode(world);
|
||||
return world;
|
||||
}
|
||||
|
||||
export function visibleCount(node: TreeNode): number {
|
||||
return node.children.reduce((total, child) => total + (child.kind === "actor" ? 1 : visibleCount(child)), 0);
|
||||
}
|
||||
|
||||
export function findNodeById(node: TreeNode, id: string | null): TreeNode | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
if (node.id === id) {
|
||||
return node;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const found = findNodeById(child, id);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function nodeMatchesQuery(node: TreeNode, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const lower = query.toLowerCase();
|
||||
const selfMatches = [node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(lower));
|
||||
return selfMatches || node.children.some((child) => nodeMatchesQuery(child, query));
|
||||
}
|
||||
118
src/services/actorCache.ts
Normal file
118
src/services/actorCache.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ActorRow } from "../types";
|
||||
|
||||
const CACHE_PREFIX = "unreal-outliner.actor.v1.";
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_CACHE_ENTRIES = 1000;
|
||||
|
||||
type CachedActor = {
|
||||
objectPath: string;
|
||||
savedAt: number;
|
||||
actor: Pick<ActorRow, "label" | "type" | "level" | "idName" | "folderPath">;
|
||||
};
|
||||
|
||||
function cacheKey(objectPath: string): string {
|
||||
return `${CACHE_PREFIX}${objectPath}`;
|
||||
}
|
||||
|
||||
function storageAvailable(): boolean {
|
||||
try {
|
||||
const key = `${CACHE_PREFIX}probe`;
|
||||
window.localStorage.setItem(key, "1");
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCachedActor(value: unknown): value is CachedActor {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as CachedActor;
|
||||
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && Boolean(candidate.actor);
|
||||
}
|
||||
|
||||
export function applyCachedActor(actor: ActorRow): ActorRow {
|
||||
if (!actor.objectPath || !storageAvailable()) {
|
||||
return actor;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(cacheKey(actor.objectPath));
|
||||
if (!raw) {
|
||||
return actor;
|
||||
}
|
||||
|
||||
const cached = JSON.parse(raw);
|
||||
if (!isCachedActor(cached) || cached.objectPath !== actor.objectPath || Date.now() - cached.savedAt > CACHE_TTL_MS) {
|
||||
window.localStorage.removeItem(cacheKey(actor.objectPath));
|
||||
return actor;
|
||||
}
|
||||
|
||||
return {
|
||||
...actor,
|
||||
...cached.actor,
|
||||
};
|
||||
} catch {
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedActor(actor: ActorRow): void {
|
||||
if (!actor.objectPath || !storageAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cached: CachedActor = {
|
||||
objectPath: actor.objectPath,
|
||||
savedAt: Date.now(),
|
||||
actor: {
|
||||
label: actor.label,
|
||||
type: actor.type,
|
||||
level: actor.level,
|
||||
idName: actor.idName,
|
||||
folderPath: actor.folderPath,
|
||||
},
|
||||
};
|
||||
window.localStorage.setItem(cacheKey(actor.objectPath), JSON.stringify(cached));
|
||||
pruneActorCache();
|
||||
} catch {
|
||||
pruneActorCache();
|
||||
}
|
||||
}
|
||||
|
||||
function pruneActorCache(): void {
|
||||
const entries: Array<{ key: string; savedAt: number }> = [];
|
||||
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (!key?.startsWith(CACHE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = JSON.parse(window.localStorage.getItem(key) ?? "");
|
||||
if (isCachedActor(cached)) {
|
||||
entries.push({ key, savedAt: cached.savedAt });
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expiredBefore = Date.now() - CACHE_TTL_MS;
|
||||
for (const entry of entries) {
|
||||
if (entry.savedAt < expiredBefore) {
|
||||
window.localStorage.removeItem(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
.filter((entry) => entry.savedAt >= expiredBefore)
|
||||
.sort((a, b) => b.savedAt - a.savedAt)
|
||||
.slice(MAX_CACHE_ENTRIES)
|
||||
.forEach((entry) => window.localStorage.removeItem(entry.key));
|
||||
}
|
||||
73
src/services/components.ts
Normal file
73
src/services/components.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { DetailProperty, ObjectComponent } from "../types";
|
||||
import { asRecord, basename, cleanObjectName, extractObjectPath, extractReturnValue, firstString } from "../lib/remoteValues";
|
||||
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
|
||||
|
||||
const disabledComponentCalls = new Set<string>();
|
||||
|
||||
function normalizeComponent(value: unknown, index: number): ObjectComponent | null {
|
||||
const objectPath = extractObjectPath(value);
|
||||
if (!objectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
const label = firstString(record, ["Name", "name", "Label", "label"], cleanObjectName(basename(objectPath)));
|
||||
const type = firstString(record, ["Class", "class", "Type", "type"], cleanObjectName(basename(objectPath).replace(/\d+$/, "")));
|
||||
|
||||
return {
|
||||
id: objectPath || `${label}-${index}`,
|
||||
label,
|
||||
type,
|
||||
objectPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function tryComponentCall(actorPath: string, functionName: string, parameters = {}): Promise<ObjectComponent[]> {
|
||||
if (disabledComponentCalls.has(functionName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await callRemoteFunction(actorPath, functionName, parameters);
|
||||
return extractReturnValue(payload)
|
||||
.map(normalizeComponent)
|
||||
.filter((component): component is ObjectComponent => Boolean(component));
|
||||
} catch (reason) {
|
||||
if (reason instanceof RemoteCallError && !reason.isTransportError) {
|
||||
disabledComponentCalls.add(functionName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackComponentsFromDetails(detailsProperties: DetailProperty[]): ObjectComponent[] {
|
||||
const components = detailsProperties
|
||||
.filter((property) => {
|
||||
const valuePath = extractObjectPath(property.value);
|
||||
return Boolean(valuePath) && (property.type.toLowerCase().includes("component") || valuePath.toLowerCase().includes("component"));
|
||||
})
|
||||
.map((property, index) => normalizeComponent(property.value, index))
|
||||
.filter((component): component is ObjectComponent => Boolean(component));
|
||||
|
||||
return Array.from(new Map(components.map((component) => [component.objectPath, component])).values());
|
||||
}
|
||||
|
||||
export async function discoverComponents(actorPath: string, detailsProperties: DetailProperty[]): Promise<ObjectComponent[]> {
|
||||
if (!actorPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const directComponents = await tryComponentCall(actorPath, "GetComponents");
|
||||
if (directComponents.length > 0) {
|
||||
return directComponents;
|
||||
}
|
||||
|
||||
const actorComponents = await tryComponentCall(actorPath, "GetComponentsByClass", {
|
||||
ComponentClass: "/Script/Engine.ActorComponent",
|
||||
});
|
||||
if (actorComponents.length > 0) {
|
||||
return actorComponents;
|
||||
}
|
||||
|
||||
return fallbackComponentsFromDetails(detailsProperties);
|
||||
}
|
||||
234
src/services/details.ts
Normal file
234
src/services/details.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { DetailControlKind, DetailProperty, ObjectDetails, RemoteObject } from "../types";
|
||||
import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues";
|
||||
|
||||
function getMetadataString(property: RemoteObject, key: string): string {
|
||||
const metadata = asRecord(property.Metadata ?? property.metadata);
|
||||
return firstString(metadata, [key, key.toLowerCase(), key.toUpperCase()]);
|
||||
}
|
||||
|
||||
function prettifyPropertyName(name: string): string {
|
||||
return name
|
||||
.replace(/^b([A-Z])/, "$1")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findPropertyValue(values: RemoteObject, name: string): unknown {
|
||||
if (name in values) {
|
||||
return values[name];
|
||||
}
|
||||
|
||||
const lowerName = name.toLowerCase();
|
||||
const key = Object.keys(values).find((candidate) => candidate.toLowerCase() === lowerName);
|
||||
return key ? values[key] : undefined;
|
||||
}
|
||||
|
||||
function extractPropertyValues(payload: unknown): RemoteObject {
|
||||
const record = asRecord(payload);
|
||||
const candidates = [
|
||||
record,
|
||||
asRecord(record.ResponseBody),
|
||||
asRecord(record.responseBody),
|
||||
asRecord(record.Properties),
|
||||
asRecord(record.properties),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const keys = Object.keys(candidate);
|
||||
if (keys.length > 0 && !keys.includes("ResponseCode") && !keys.includes("RequestId")) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function inferControlKind(type: string, value: unknown): DetailControlKind {
|
||||
const lowerType = type.toLowerCase();
|
||||
if (detailAssetPath({ value, type } as DetailProperty)) {
|
||||
return "asset";
|
||||
}
|
||||
if (typeof value === "boolean" || lowerType.includes("bool")) {
|
||||
return "boolean";
|
||||
}
|
||||
if (typeof value === "number" || lowerType.includes("float") || lowerType.includes("double") || lowerType.includes("int")) {
|
||||
return "number";
|
||||
}
|
||||
if (isVectorLikeValue(value)) {
|
||||
return ["Roll", "Pitch", "Yaw"].every((key) => key in asRecord(value)) ? "rotator" : "vector";
|
||||
}
|
||||
if (typeof value === "string" || lowerType.includes("name") || lowerType.includes("string") || lowerType.includes("text")) {
|
||||
return "string";
|
||||
}
|
||||
return "readonly";
|
||||
}
|
||||
|
||||
function canEditProperty(name: string, value: unknown, controlKind: DetailControlKind): boolean {
|
||||
if (!name || value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (controlKind === "readonly" || controlKind === "asset") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "object" && !isVectorLikeValue(value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isVectorLikeValue(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
return ["X", "Y", "Z"].every((key) => key in record) || ["Roll", "Pitch", "Yaw"].every((key) => key in record);
|
||||
}
|
||||
|
||||
export function normalizeDetails(objectPath: string, describePayload: unknown, propertyPayload: unknown): ObjectDetails {
|
||||
const description = asRecord(describePayload);
|
||||
const values = extractPropertyValues(propertyPayload);
|
||||
const properties = Array.isArray(description.Properties)
|
||||
? description.Properties
|
||||
: Array.isArray(description.properties)
|
||||
? description.properties
|
||||
: [];
|
||||
|
||||
return {
|
||||
objectPath,
|
||||
name: firstString(description, ["Name", "name"], "Object"),
|
||||
className: firstString(description, ["Class", "class"], "UObject"),
|
||||
properties: properties.map((rawProperty): DetailProperty => {
|
||||
const property = asRecord(rawProperty);
|
||||
const name = firstString(property, ["Name", "name"], "Property");
|
||||
const type = firstString(property, ["Type", "type"], "Property");
|
||||
const value = findPropertyValue(values, name);
|
||||
const controlKind = inferControlKind(type, value);
|
||||
const editable = canEditProperty(name, value, controlKind);
|
||||
return {
|
||||
name,
|
||||
displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name),
|
||||
category: getMetadataString(property, "Category") || "General",
|
||||
type,
|
||||
value,
|
||||
description: firstString(property, ["Description", "description"]),
|
||||
access: editable ? "WRITE_TRANSACTION_ACCESS" : "READ_ACCESS",
|
||||
editable,
|
||||
controlKind,
|
||||
propertyPath: name,
|
||||
sourceObjectPath: objectPath,
|
||||
writeValue: value,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDetailValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "Unavailable";
|
||||
}
|
||||
if (value === null) {
|
||||
return "None";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "True" : "False";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value || "None";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
const vectorKeys = ["X", "Y", "Z"].filter((key) => key in record);
|
||||
if (vectorKeys.length === 3) {
|
||||
return vectorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" ");
|
||||
}
|
||||
|
||||
const rotatorKeys = ["Roll", "Pitch", "Yaw"].filter((key) => key in record);
|
||||
if (rotatorKeys.length === 3) {
|
||||
return rotatorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" ");
|
||||
}
|
||||
|
||||
const objectPath = extractObjectPath(value);
|
||||
if (objectPath) {
|
||||
return cleanObjectName(basename(objectPath));
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function detailAssetPath(property: DetailProperty): string {
|
||||
const valuePath = extractObjectPath(property.value);
|
||||
if (valuePath.startsWith("/Game/") || valuePath.startsWith("/Engine/")) {
|
||||
return valuePath;
|
||||
}
|
||||
|
||||
if (typeof property.value === "string" && (property.value.startsWith("/Game/") || property.value.startsWith("/Engine/"))) {
|
||||
return property.value;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isBooleanProperty(property: DetailProperty): boolean {
|
||||
return typeof property.value === "boolean" || property.type.toLowerCase().includes("bool");
|
||||
}
|
||||
|
||||
export function isVectorLikeProperty(property: DetailProperty): boolean {
|
||||
return isVectorLikeValue(property.value);
|
||||
}
|
||||
|
||||
export function vectorParts(value: unknown): Array<{ label: string; value: string }> {
|
||||
const record = asRecord(value);
|
||||
const keys = ["X", "Y", "Z"].every((key) => key in record) ? ["X", "Y", "Z"] : ["Roll", "Pitch", "Yaw"];
|
||||
return keys.map((key) => ({ label: key, value: formatDetailValue(record[key]) }));
|
||||
}
|
||||
|
||||
export function normalizeThumbnailPayload(payload: unknown): string {
|
||||
if (typeof payload === "string") {
|
||||
return payload.startsWith("data:") ? payload : `data:image/png;base64,${payload}`;
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
const value = firstString(record, ["Thumbnail", "thumbnail", "Image", "image", "Data", "data", "Base64", "base64"]);
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.startsWith("data:") ? value : `data:image/png;base64,${value}`;
|
||||
}
|
||||
|
||||
export function coerceDetailInputValue(property: DetailProperty, value: string | boolean | number | unknown): unknown {
|
||||
if (property.controlKind === "boolean") {
|
||||
return Boolean(value);
|
||||
}
|
||||
if (property.controlKind === "number") {
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : property.value;
|
||||
}
|
||||
if (property.controlKind === "string") {
|
||||
return String(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function withUpdatedVectorPart(property: DetailProperty, part: string, value: string): unknown {
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return property.value;
|
||||
}
|
||||
|
||||
return {
|
||||
...asRecord(property.writeValue ?? property.value),
|
||||
[part]: numberValue,
|
||||
};
|
||||
}
|
||||
120
src/services/detailsCache.ts
Normal file
120
src/services/detailsCache.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { CachedObjectDetails, ObjectDetails } from "../types";
|
||||
|
||||
const CACHE_PREFIX = "unreal-outliner.details.";
|
||||
const CACHE_VERSION = "v1";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_CACHE_ENTRIES = 150;
|
||||
|
||||
function cacheKey(objectPath: string): string {
|
||||
return `${CACHE_PREFIX}${CACHE_VERSION}.${objectPath}`;
|
||||
}
|
||||
|
||||
function isCachedObjectDetails(value: unknown): value is CachedObjectDetails {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as CachedObjectDetails;
|
||||
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && Boolean(candidate.details);
|
||||
}
|
||||
|
||||
function storageAvailable(): boolean {
|
||||
try {
|
||||
const key = `${CACHE_PREFIX}probe`;
|
||||
window.localStorage.setItem(key, "1");
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedDetails(objectPath: string): ObjectDetails | null {
|
||||
if (!storageAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(cacheKey(objectPath));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isCachedObjectDetails(parsed) || parsed.objectPath !== objectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
|
||||
window.localStorage.removeItem(cacheKey(objectPath));
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.details;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedDetails(objectPath: string, details: ObjectDetails): void {
|
||||
if (!storageAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry: CachedObjectDetails = {
|
||||
objectPath,
|
||||
savedAt: Date.now(),
|
||||
details,
|
||||
};
|
||||
window.localStorage.setItem(cacheKey(objectPath), JSON.stringify(entry));
|
||||
pruneDetailsCache();
|
||||
} catch {
|
||||
pruneDetailsCache();
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateCachedDetails(objectPath: string): void {
|
||||
if (!storageAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(cacheKey(objectPath));
|
||||
} catch {
|
||||
// localStorage can be unavailable in private contexts; cache invalidation is best effort.
|
||||
}
|
||||
}
|
||||
|
||||
function pruneDetailsCache(): void {
|
||||
const entries: Array<{ key: string; savedAt: number }> = [];
|
||||
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (!key?.startsWith(`${CACHE_PREFIX}${CACHE_VERSION}.`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(key) ?? "");
|
||||
if (isCachedObjectDetails(parsed)) {
|
||||
entries.push({ key, savedAt: parsed.savedAt });
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expiredBefore = Date.now() - CACHE_TTL_MS;
|
||||
for (const entry of entries) {
|
||||
if (entry.savedAt < expiredBefore) {
|
||||
window.localStorage.removeItem(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
.filter((entry) => entry.savedAt >= expiredBefore)
|
||||
.sort((a, b) => b.savedAt - a.savedAt)
|
||||
.slice(MAX_CACHE_ENTRIES)
|
||||
.forEach((entry) => window.localStorage.removeItem(entry.key));
|
||||
}
|
||||
240
src/services/remoteControl.ts
Normal file
240
src/services/remoteControl.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { RemoteObject, RemoteTransport } from "../types";
|
||||
import { asRecord, firstString } from "../lib/remoteValues";
|
||||
|
||||
const UNREAL_WS_URL = import.meta.env.VITE_UNREAL_WS_URL ?? "ws://127.0.0.1:30020";
|
||||
|
||||
export class RemoteCallError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly isTransportError = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
let activeTransport: RemoteTransport = "HTTP";
|
||||
let remoteSocket: WebSocket | null = null;
|
||||
let remoteSocketPromise: Promise<WebSocket> | null = null;
|
||||
let nextRequestId = 1;
|
||||
const pendingSocketCalls = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (payload: unknown) => void;
|
||||
reject: (reason: Error) => void;
|
||||
timeoutId: number;
|
||||
}
|
||||
>();
|
||||
|
||||
export function getActiveTransport(): RemoteTransport {
|
||||
return activeTransport;
|
||||
}
|
||||
|
||||
async function parseRemoteResponse(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyRemoteError(payload: unknown): string {
|
||||
if (typeof payload === "string") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
const direct = firstString(record, ["errorMessage", "ErrorMessage", "message", "Message", "error", "Error"]);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function closeRemoteSocket(reason: Error) {
|
||||
for (const call of pendingSocketCalls.values()) {
|
||||
window.clearTimeout(call.timeoutId);
|
||||
call.reject(reason);
|
||||
}
|
||||
pendingSocketCalls.clear();
|
||||
remoteSocket = null;
|
||||
remoteSocketPromise = null;
|
||||
}
|
||||
|
||||
function parseSocketMessage(data: string | Blob): Promise<RemoteObject> {
|
||||
if (typeof data === "string") {
|
||||
return Promise.resolve(asRecord(JSON.parse(data)));
|
||||
}
|
||||
|
||||
return data.text().then((text) => asRecord(JSON.parse(text)));
|
||||
}
|
||||
|
||||
function connectRemoteSocket(): Promise<WebSocket> {
|
||||
if (remoteSocket?.readyState === WebSocket.OPEN) {
|
||||
return Promise.resolve(remoteSocket);
|
||||
}
|
||||
|
||||
if (remoteSocketPromise) {
|
||||
return remoteSocketPromise;
|
||||
}
|
||||
|
||||
remoteSocketPromise = new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(UNREAL_WS_URL);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
socket.close();
|
||||
reject(new RemoteCallError(`Timed out connecting to ${UNREAL_WS_URL}`, true));
|
||||
remoteSocketPromise = null;
|
||||
}, 1500);
|
||||
|
||||
socket.onopen = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
remoteSocket = socket;
|
||||
activeTransport = "WebSocket";
|
||||
resolve(socket);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
reject(new RemoteCallError(`Could not connect to ${UNREAL_WS_URL}`, true));
|
||||
remoteSocket = null;
|
||||
remoteSocketPromise = null;
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
closeRemoteSocket(new RemoteCallError("Unreal Remote Control WebSocket closed", true));
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
parseSocketMessage(message.data)
|
||||
.then((payload) => {
|
||||
const requestId = Number(payload.RequestId ?? payload.Id ?? payload.id);
|
||||
const pending = pendingSocketCalls.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSocketCalls.delete(requestId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
|
||||
const responseCode = Number(payload.ResponseCode ?? payload.responseCode ?? 200);
|
||||
const responseBody = payload.ResponseBody ?? payload.responseBody ?? payload;
|
||||
if (responseCode >= 200 && responseCode < 300) {
|
||||
pending.resolve(responseBody);
|
||||
} else {
|
||||
pending.reject(new RemoteCallError(`Remote Control returned ${responseCode}: ${stringifyRemoteError(responseBody)}`));
|
||||
}
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.warn("Could not parse Unreal Remote Control WebSocket message", reason);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return remoteSocketPromise;
|
||||
}
|
||||
|
||||
async function callRemoteHttpRouteHttp(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise<unknown> {
|
||||
activeTransport = "HTTP";
|
||||
const response = await fetch(url, {
|
||||
method: verb,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const payload = await parseRemoteResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RemoteCallError(`Remote Control returned ${response.status} ${response.statusText}: ${stringifyRemoteError(payload)}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function callRemoteHttpRouteWs(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise<unknown> {
|
||||
const socket = await connectRemoteSocket();
|
||||
const requestId = nextRequestId++;
|
||||
|
||||
const response = new Promise<unknown>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
pendingSocketCalls.delete(requestId);
|
||||
reject(new RemoteCallError(`Timed out waiting for WebSocket response ${requestId}`, true));
|
||||
}, 8000);
|
||||
|
||||
pendingSocketCalls.set(requestId, { resolve, reject, timeoutId });
|
||||
});
|
||||
|
||||
try {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
MessageName: "http",
|
||||
Id: requestId,
|
||||
Parameters: {
|
||||
Url: url,
|
||||
Verb: verb,
|
||||
Body: body,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (reason) {
|
||||
pendingSocketCalls.delete(requestId);
|
||||
throw new RemoteCallError(reason instanceof Error ? reason.message : "Could not send WebSocket request", true);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function callRemoteHttpRoute(
|
||||
url: string,
|
||||
verb: "GET" | "PUT" | "POST" | "DELETE",
|
||||
body?: RemoteObject,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await callRemoteHttpRouteWs(url, verb, body);
|
||||
} catch (reason) {
|
||||
if (reason instanceof RemoteCallError && reason.isTransportError) {
|
||||
return callRemoteHttpRouteHttp(url, verb, body);
|
||||
}
|
||||
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
export async function callRemoteFunction(objectPath: string, functionName: string, parameters: RemoteObject = {}): Promise<unknown> {
|
||||
return callRemoteHttpRoute("/remote/object/call", "PUT", {
|
||||
objectPath,
|
||||
functionName,
|
||||
parameters,
|
||||
generateTransaction: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function describeRemoteObject(objectPath: string): Promise<unknown> {
|
||||
return callRemoteHttpRoute("/remote/object/describe", "PUT", { objectPath });
|
||||
}
|
||||
|
||||
export async function readRemoteObjectProperties(objectPath: string): Promise<unknown> {
|
||||
return callRemoteHttpRoute("/remote/object/property", "PUT", {
|
||||
objectPath,
|
||||
access: "READ_ACCESS",
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeRemoteObjectProperty(objectPath: string, propertyName: string, value: unknown): Promise<unknown> {
|
||||
return callRemoteHttpRoute("/remote/object/property", "PUT", {
|
||||
objectPath,
|
||||
propertyName,
|
||||
access: "WRITE_TRANSACTION_ACCESS",
|
||||
propertyValue: {
|
||||
[propertyName]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
|
||||
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
|
||||
}
|
||||
106
src/services/thumbnailCache.ts
Normal file
106
src/services/thumbnailCache.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
const CACHE_PREFIX = "unreal-outliner.thumbnail.v1.";
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const MAX_CACHE_ENTRIES = 100;
|
||||
|
||||
type CachedThumbnail = {
|
||||
objectPath: string;
|
||||
savedAt: number;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
function cacheKey(objectPath: string): string {
|
||||
return `${CACHE_PREFIX}${objectPath}`;
|
||||
}
|
||||
|
||||
function isCachedThumbnail(value: unknown): value is CachedThumbnail {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as CachedThumbnail;
|
||||
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && typeof candidate.dataUrl === "string";
|
||||
}
|
||||
|
||||
function storageAvailable(): boolean {
|
||||
try {
|
||||
const key = `${CACHE_PREFIX}probe`;
|
||||
window.localStorage.setItem(key, "1");
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedThumbnail(objectPath: string): string {
|
||||
if (!storageAvailable()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(cacheKey(objectPath));
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isCachedThumbnail(parsed) || parsed.objectPath !== objectPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
|
||||
window.localStorage.removeItem(cacheKey(objectPath));
|
||||
return "";
|
||||
}
|
||||
|
||||
return parsed.dataUrl;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedThumbnail(objectPath: string, dataUrl: string): void {
|
||||
if (!storageAvailable() || !dataUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey(objectPath), JSON.stringify({ objectPath, savedAt: Date.now(), dataUrl }));
|
||||
pruneThumbnailCache();
|
||||
} catch {
|
||||
pruneThumbnailCache();
|
||||
}
|
||||
}
|
||||
|
||||
function pruneThumbnailCache(): void {
|
||||
const entries: Array<{ key: string; savedAt: number }> = [];
|
||||
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (!key?.startsWith(CACHE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(key) ?? "");
|
||||
if (isCachedThumbnail(parsed)) {
|
||||
entries.push({ key, savedAt: parsed.savedAt });
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expiredBefore = Date.now() - CACHE_TTL_MS;
|
||||
for (const entry of entries) {
|
||||
if (entry.savedAt < expiredBefore) {
|
||||
window.localStorage.removeItem(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
.filter((entry) => entry.savedAt >= expiredBefore)
|
||||
.sort((a, b) => b.savedAt - a.savedAt)
|
||||
.slice(MAX_CACHE_ENTRIES)
|
||||
.forEach((entry) => window.localStorage.removeItem(entry.key));
|
||||
}
|
||||
257
src/services/transform.ts
Normal file
257
src/services/transform.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { ObjectTransform, RemoteObject, TransformField, TransformTargetKind, TransformVector } from "../types";
|
||||
import { asRecord } from "../lib/remoteValues";
|
||||
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
|
||||
|
||||
const disabledTransformCalls = new Set<string>();
|
||||
|
||||
const actorGetAttempts = {
|
||||
location: ["GetActorLocation"],
|
||||
rotation: ["GetActorRotation"],
|
||||
scale: ["GetActorScale3D"],
|
||||
} satisfies Record<TransformField, string[]>;
|
||||
|
||||
const componentGetAttempts = {
|
||||
location: ["GetActorLocation", "K2_GetActorLocation", "GetComponentLocation", "K2_GetComponentLocation"],
|
||||
rotation: ["GetActorRotation", "K2_GetActorRotation", "GetComponentRotation", "K2_GetComponentRotation"],
|
||||
scale: ["GetActorScale3D", "GetActorScale", "GetComponentScale", "GetComponentScale3D"],
|
||||
} satisfies Record<TransformField, string[]>;
|
||||
|
||||
const actorSetAttempts = {
|
||||
location: [{ functionName: "SetActorLocation", parameterName: "NewLocation" }],
|
||||
rotation: [{ functionName: "SetActorRotation", parameterName: "NewRotation" }],
|
||||
scale: [{ functionName: "SetActorScale3D", parameterName: "NewScale3D" }],
|
||||
} satisfies Record<TransformField, Array<{ functionName: string; parameterName: string }>>;
|
||||
|
||||
const componentSetAttempts = {
|
||||
location: [
|
||||
{ functionName: "SetWorldLocation", parameterName: "NewLocation" },
|
||||
{ functionName: "K2_SetWorldLocation", parameterName: "NewLocation" },
|
||||
],
|
||||
rotation: [
|
||||
{ functionName: "SetWorldRotation", parameterName: "NewRotation" },
|
||||
{ functionName: "K2_SetWorldRotation", parameterName: "NewRotation" },
|
||||
],
|
||||
scale: [
|
||||
{ functionName: "SetActorScale3D", parameterName: "NewScale3D" },
|
||||
{ functionName: "SetWorldScale3D", parameterName: "NewScale" },
|
||||
],
|
||||
} satisfies Record<TransformField, Array<{ functionName: string; parameterName: string }>>;
|
||||
|
||||
function callKey(objectPath: string, functionName: string): string {
|
||||
return `${objectPath}:${functionName}`;
|
||||
}
|
||||
|
||||
function asUnrealVector(vector: TransformVector): RemoteObject {
|
||||
return {
|
||||
X: vector.x,
|
||||
Y: vector.y,
|
||||
Z: vector.z,
|
||||
};
|
||||
}
|
||||
|
||||
function attemptsForTarget<T>(targetKind: TransformTargetKind, actorAttempts: Record<TransformField, T>, componentAttempts: Record<TransformField, T>) {
|
||||
return targetKind === "actor" ? actorAttempts : componentAttempts;
|
||||
}
|
||||
|
||||
export function createDefaultTransform(objectPath: string, targetKind: TransformTargetKind): ObjectTransform {
|
||||
return {
|
||||
objectPath,
|
||||
targetKind,
|
||||
location: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
readStatus: {
|
||||
location: false,
|
||||
rotation: false,
|
||||
scale: false,
|
||||
},
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVector(value: unknown, fallback: TransformVector): { value: TransformVector; found: boolean } {
|
||||
const foundRecord = findVectorRecord(value);
|
||||
const vectorRecord = foundRecord ?? asRecord(value);
|
||||
const x = Number(vectorRecord.X ?? vectorRecord.x ?? vectorRecord.Roll ?? vectorRecord.roll);
|
||||
const y = Number(vectorRecord.Y ?? vectorRecord.y ?? vectorRecord.Pitch ?? vectorRecord.pitch);
|
||||
const z = Number(vectorRecord.Z ?? vectorRecord.z ?? vectorRecord.Yaw ?? vectorRecord.yaw);
|
||||
|
||||
const parsedValue = {
|
||||
x: Number.isFinite(x) ? x : fallback.x,
|
||||
y: Number.isFinite(y) ? y : fallback.y,
|
||||
z: Number.isFinite(z) ? z : fallback.z,
|
||||
};
|
||||
|
||||
return {
|
||||
value: parsedValue,
|
||||
found: Boolean(foundRecord) && Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z),
|
||||
};
|
||||
}
|
||||
|
||||
function hasVectorKeys(record: RemoteObject): boolean {
|
||||
const keys = Object.keys(record);
|
||||
const lowerKeys = keys.map((key) => key.toLowerCase());
|
||||
return (
|
||||
(lowerKeys.includes("x") && lowerKeys.includes("y") && lowerKeys.includes("z")) ||
|
||||
(lowerKeys.includes("roll") && lowerKeys.includes("pitch") && lowerKeys.includes("yaw"))
|
||||
);
|
||||
}
|
||||
|
||||
function findVectorRecord(value: unknown, depth = 0): RemoteObject | null {
|
||||
if (depth > 4 || !value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (hasVectorKeys(record)) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const priorityKeys = ["ReturnValue", "returnValue", "Result", "result", "Location", "Rotation", "Scale", "Translation"];
|
||||
for (const key of priorityKeys) {
|
||||
const nested = findVectorRecord(record[key], depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(record)) {
|
||||
const nested = findVectorRecord(nestedValue, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function tryGetTransformField(
|
||||
objectPath: string,
|
||||
targetKind: TransformTargetKind,
|
||||
field: TransformField,
|
||||
fallback: TransformVector,
|
||||
): Promise<TransformVector> {
|
||||
const getAttempts = attemptsForTarget(targetKind, actorGetAttempts, componentGetAttempts);
|
||||
for (const functionName of getAttempts[field]) {
|
||||
const key = callKey(objectPath, functionName);
|
||||
if (disabledTransformCalls.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let timeoutId = 0;
|
||||
try {
|
||||
const payload = await Promise.race([
|
||||
callRemoteFunction(objectPath, functionName),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => reject(new RemoteCallError(`Timed out reading ${field}`, true)), 1200);
|
||||
}),
|
||||
]);
|
||||
return normalizeVector(payload, fallback).value;
|
||||
} catch (reason) {
|
||||
if (reason instanceof RemoteCallError && !reason.isTransportError) {
|
||||
disabledTransformCalls.add(key);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function readTransformField(
|
||||
objectPath: string,
|
||||
targetKind: TransformTargetKind,
|
||||
field: TransformField,
|
||||
fallback: TransformVector,
|
||||
): Promise<{ value: TransformVector; found: boolean }> {
|
||||
const getAttempts = attemptsForTarget(targetKind, actorGetAttempts, componentGetAttempts);
|
||||
for (const functionName of getAttempts[field]) {
|
||||
const key = callKey(objectPath, functionName);
|
||||
if (disabledTransformCalls.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let timeoutId = 0;
|
||||
try {
|
||||
const payload = await Promise.race([
|
||||
callRemoteFunction(objectPath, functionName),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => reject(new RemoteCallError(`Timed out reading ${field}`, true)), 1200);
|
||||
}),
|
||||
]);
|
||||
return normalizeVector(payload, fallback);
|
||||
} catch (reason) {
|
||||
if (reason instanceof RemoteCallError && !reason.isTransportError) {
|
||||
disabledTransformCalls.add(key);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
return { value: fallback, found: false };
|
||||
}
|
||||
|
||||
export async function readObjectTransform(objectPath: string, targetKind: TransformTargetKind): Promise<ObjectTransform | null> {
|
||||
if (!objectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = createDefaultTransform(objectPath, targetKind);
|
||||
const [location, rotation, scale] = await Promise.all([
|
||||
readTransformField(objectPath, targetKind, "location", { x: 0, y: 0, z: 0 }),
|
||||
readTransformField(objectPath, targetKind, "rotation", { x: 0, y: 0, z: 0 }),
|
||||
readTransformField(objectPath, targetKind, "scale", { x: 1, y: 1, z: 1 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
location: location.value,
|
||||
rotation: rotation.value,
|
||||
scale: scale.value,
|
||||
readStatus: {
|
||||
location: location.found,
|
||||
rotation: rotation.found,
|
||||
scale: scale.found,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setterParameters(parameterName: string, vector: TransformVector): RemoteObject {
|
||||
return {
|
||||
[parameterName]: asUnrealVector(vector),
|
||||
bSweep: false,
|
||||
bTeleport: true,
|
||||
bTeleportPhysics: true,
|
||||
SweepHitResult: {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeObjectTransformField(
|
||||
objectPath: string,
|
||||
targetKind: TransformTargetKind,
|
||||
field: TransformField,
|
||||
value: TransformVector,
|
||||
): Promise<void> {
|
||||
const setAttempts = attemptsForTarget(targetKind, actorSetAttempts, componentSetAttempts);
|
||||
for (const attempt of setAttempts[field]) {
|
||||
const key = callKey(objectPath, attempt.functionName);
|
||||
if (disabledTransformCalls.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await callRemoteFunction(objectPath, attempt.functionName, setterParameters(attempt.parameterName, value));
|
||||
return;
|
||||
} catch (reason) {
|
||||
if (reason instanceof RemoteCallError && !reason.isTransportError) {
|
||||
disabledTransformCalls.add(key);
|
||||
} else {
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No writable ${field} transform function is available for this object.`);
|
||||
}
|
||||
142
src/services/unrealActors.ts
Normal file
142
src/services/unrealActors.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { ActorRow, RemoteObject } from "../types";
|
||||
import {
|
||||
asRecord,
|
||||
basename,
|
||||
cleanObjectName,
|
||||
deriveLevelNameFromPath,
|
||||
extractFirstReturnString,
|
||||
extractObjectPath,
|
||||
firstString,
|
||||
nestedString,
|
||||
} from "../lib/remoteValues";
|
||||
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
|
||||
import { writeCachedActor } from "./actorCache";
|
||||
|
||||
const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem";
|
||||
const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary";
|
||||
|
||||
const disabledActorFunctions = new Set<string>();
|
||||
const actorStringCallCache = new Map<string, string>();
|
||||
|
||||
function deriveType(actor: RemoteObject): string {
|
||||
const direct = firstString(actor, ["Class", "class", "ClassName", "className", "Type", "type"]);
|
||||
if (direct) {
|
||||
return basename(direct).replace(/^BP_/, "");
|
||||
}
|
||||
const path = firstString(actor, ["Path", "path", "ObjectPath", "objectPath"]);
|
||||
const generatedClass = /\.(.+?)_C_\d+$/.exec(path)?.[1];
|
||||
return generatedClass ? basename(generatedClass) : "Actor";
|
||||
}
|
||||
|
||||
export function normalizeActor(value: unknown, index: number): ActorRow {
|
||||
const actor = asRecord(value);
|
||||
const objectPath = extractObjectPath(value);
|
||||
const pathName = cleanObjectName(basename(objectPath));
|
||||
const label = firstString(actor, ["ActorLabel", "actorLabel", "Label", "label", "DisplayName", "displayName"], pathName || `Actor ${index + 1}`);
|
||||
const idName = firstString(actor, ["Name", "name", "IdName", "idName"], pathName || label);
|
||||
const level =
|
||||
deriveLevelNameFromPath(objectPath) ||
|
||||
nestedString(actor, ["Level", "level", "Outer", "outer", "Package", "package"], "Persistent Level");
|
||||
const folderPath = firstString(actor, ["FolderPath", "folderPath", "Folder", "folder", "ActorFolderPath", "actorFolderPath"]);
|
||||
|
||||
return {
|
||||
id: objectPath || `${label}-${index}`,
|
||||
label,
|
||||
type: deriveType(actor),
|
||||
level: deriveLevelNameFromPath(level) || basename(level).replace(/^UEDPIE_\d+_/, "") || "Persistent Level",
|
||||
idName,
|
||||
folderPath,
|
||||
objectPath,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeActorDetails(actor: ActorRow, details: Partial<ActorRow>): ActorRow {
|
||||
return {
|
||||
...actor,
|
||||
label: details.label && details.label !== "Actor" ? details.label : actor.label,
|
||||
type: details.type && details.type !== "Actor" ? details.type : actor.type,
|
||||
level: details.level && details.level !== "Persistent Level" ? details.level : actor.level,
|
||||
idName: details.idName && details.idName !== "Actor" ? details.idName : actor.idName,
|
||||
folderPath: details.folderPath || actor.folderPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllLevelActors(): Promise<unknown> {
|
||||
const attempts = [
|
||||
{ objectPath: EDITOR_ACTOR_SUBSYSTEM, functionName: "GetAllLevelActors" },
|
||||
{ objectPath: EDITOR_LEVEL_LIBRARY, functionName: "GetAllLevelActors" },
|
||||
];
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
return await callRemoteFunction(attempt.objectPath, attempt.functionName);
|
||||
} catch (reason) {
|
||||
errors.push(`${attempt.objectPath}.${attempt.functionName}: ${reason instanceof Error ? reason.message : String(reason)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errors.join("\n\n"));
|
||||
}
|
||||
|
||||
async function tryRemoteString(objectPath: string, functionName: string): Promise<string> {
|
||||
if (disabledActorFunctions.has(functionName)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cacheKey = `${objectPath}|${functionName}`;
|
||||
if (actorStringCallCache.has(cacheKey)) {
|
||||
return actorStringCallCache.get(cacheKey) ?? "";
|
||||
}
|
||||
|
||||
try {
|
||||
const value = extractFirstReturnString(await callRemoteFunction(objectPath, functionName));
|
||||
actorStringCallCache.set(cacheKey, value);
|
||||
return value;
|
||||
} catch (reason) {
|
||||
if (reason instanceof RemoteCallError && !reason.isTransportError) {
|
||||
disabledActorFunctions.add(functionName);
|
||||
}
|
||||
actorStringCallCache.set(cacheKey, "");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichActor(actor: ActorRow): Promise<ActorRow> {
|
||||
if (!actor.objectPath) {
|
||||
return actor;
|
||||
}
|
||||
|
||||
const [label, folderPath] = await Promise.all([
|
||||
tryRemoteString(actor.objectPath, "GetActorLabel"),
|
||||
tryRemoteString(actor.objectPath, "GetFolderPath"),
|
||||
]);
|
||||
|
||||
const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : "";
|
||||
|
||||
const enrichedActor = mergeActorDetails(actor, {
|
||||
label: label || actor.label,
|
||||
folderPath: folderPath || actor.folderPath,
|
||||
level: deriveLevelNameFromPath(actor.objectPath) || actor.level,
|
||||
type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type,
|
||||
});
|
||||
|
||||
writeCachedActor(enrichedActor);
|
||||
return enrichedActor;
|
||||
}
|
||||
|
||||
export async function enrichActors(actors: ActorRow[]): Promise<ActorRow[]> {
|
||||
const sampleActor = actors.find((actor) => actor.objectPath);
|
||||
if (sampleActor) {
|
||||
await Promise.all([
|
||||
tryRemoteString(sampleActor.objectPath, "GetActorLabel"),
|
||||
tryRemoteString(sampleActor.objectPath, "GetFolderPath"),
|
||||
]);
|
||||
}
|
||||
|
||||
const enriched: ActorRow[] = [];
|
||||
for (let index = 0; index < actors.length; index += 4) {
|
||||
enriched.push(...(await Promise.all(actors.slice(index, index + 4).map(enrichActor))));
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
447
src/styles.css
447
src/styles.css
@@ -1,6 +1,7 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 85%;
|
||||
background: #101010;
|
||||
color: #c7c7c7;
|
||||
}
|
||||
@@ -23,16 +24,16 @@ input {
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(1220px, 100%);
|
||||
min-height: calc(100vh - 20px);
|
||||
height: calc(100vh - 20px);
|
||||
min-height: calc(100vh - 16px);
|
||||
height: calc(100vh - 16px);
|
||||
display: grid;
|
||||
grid-template-rows: 48px 56px 36px 1fr 50px;
|
||||
grid-template-rows: 41px 48px 31px 1fr 43px;
|
||||
border: 1px solid #0a0a0a;
|
||||
background: #171717;
|
||||
box-shadow: inset 0 0 0 1px #232323;
|
||||
@@ -41,7 +42,7 @@ input {
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 48px;
|
||||
height: 41px;
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #0c0c0c;
|
||||
}
|
||||
@@ -49,9 +50,9 @@ input {
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 240px;
|
||||
padding: 0 14px;
|
||||
gap: 9px;
|
||||
min-width: 204px;
|
||||
padding: 0 12px;
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
border-right: 1px solid #111;
|
||||
@@ -69,16 +70,16 @@ input {
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 30px minmax(160px, 1fr) 36px 36px 36px;
|
||||
grid-template-columns: 31px 26px minmax(136px, 1fr) 31px 31px 31px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
gap: 7px;
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
@@ -99,12 +100,12 @@ input {
|
||||
}
|
||||
|
||||
.search {
|
||||
height: 36px;
|
||||
height: 31px;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 30px 1fr 24px;
|
||||
grid-template-columns: 26px 1fr 20px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
padding: 0 7px;
|
||||
color: #cfcfcf;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #343434;
|
||||
@@ -129,7 +130,7 @@ input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
@@ -138,11 +139,11 @@ input {
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(260px, 1.3fr) minmax(140px, 0.55fr) minmax(130px, 0.52fr) minmax(150px, 0.62fr);
|
||||
grid-template-columns: 32px minmax(221px, 1.3fr) minmax(119px, 0.55fr) minmax(111px, 0.52fr) minmax(128px, 0.62fr);
|
||||
align-items: center;
|
||||
background: #303030;
|
||||
color: #c7c7c7;
|
||||
font-size: 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.columns span {
|
||||
@@ -162,10 +163,44 @@ input {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(180px, 1fr) minmax(250px, 0.9fr);
|
||||
grid-template-rows:
|
||||
minmax(120px, calc(var(--outliner-ratio) * 100%))
|
||||
8px
|
||||
minmax(190px, calc(var(--details-ratio) * 100%));
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
.pane-splitter {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 8px;
|
||||
background: #101010;
|
||||
border-top: 1px solid #2e2e2e;
|
||||
border-bottom: 1px solid #060606;
|
||||
cursor: row-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.pane-splitter span {
|
||||
width: 54px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: #565656;
|
||||
box-shadow: 0 1px 0 #050505;
|
||||
}
|
||||
|
||||
.pane-splitter:hover,
|
||||
.pane-splitter:focus-visible {
|
||||
background: #171f27;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pane-splitter:hover span,
|
||||
.pane-splitter:focus-visible span {
|
||||
background: #2d86d8;
|
||||
}
|
||||
|
||||
.rows {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -174,10 +209,10 @@ input {
|
||||
|
||||
.outliner-row {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
height: 30px;
|
||||
min-width: 646px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(298px, 1.3fr) minmax(140px, 0.55fr) minmax(130px, 0.52fr) minmax(150px, 0.62fr);
|
||||
grid-template-columns: minmax(253px, 1.3fr) minmax(119px, 0.55fr) minmax(111px, 0.52fr) minmax(128px, 0.62fr);
|
||||
align-items: center;
|
||||
color: #bebebe;
|
||||
background: transparent;
|
||||
@@ -202,20 +237,20 @@ input {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 12px;
|
||||
font-size: 20px;
|
||||
padding: 0 10px;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.item-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: 42px;
|
||||
gap: 4px;
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
width: calc(var(--depth) * 18px);
|
||||
width: calc(var(--depth) * 15px);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -254,34 +289,34 @@ input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
gap: 14px;
|
||||
padding: 0 17px;
|
||||
color: #cfcfcf;
|
||||
background: #303030;
|
||||
font-size: 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 220px;
|
||||
min-height: 187px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
gap: 9px;
|
||||
padding: 20px;
|
||||
color: #bdbdbd;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty strong {
|
||||
color: #f0f0f0;
|
||||
font-size: 22px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 38px 58px 44px 1fr;
|
||||
grid-template-rows: 32px 41px 100px 37px 1fr;
|
||||
border-top: 2px solid #0d0d0d;
|
||||
background: #202020;
|
||||
color: #cfcfcf;
|
||||
@@ -296,11 +331,11 @@ input {
|
||||
.details-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 210px;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
min-width: 179px;
|
||||
padding: 0 10px;
|
||||
color: #c7c7c7;
|
||||
font-size: 20px;
|
||||
font-size: 1rem;
|
||||
background: #232323;
|
||||
border-right: 1px solid #101010;
|
||||
}
|
||||
@@ -320,10 +355,10 @@ input {
|
||||
|
||||
.details-object {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
grid-template-columns: 24px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
gap: 9px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #171717;
|
||||
background: #242424;
|
||||
}
|
||||
@@ -339,32 +374,93 @@ input {
|
||||
|
||||
.details-object strong {
|
||||
color: #f1f1f1;
|
||||
font-size: 18px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.details-object span {
|
||||
margin-top: 2px;
|
||||
color: #a8a8a8;
|
||||
font-size: 14px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.component-list {
|
||||
min-height: 0;
|
||||
padding: 5px 7px 7px;
|
||||
background: #181818;
|
||||
border: 1px solid #242424;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.component-row {
|
||||
height: 29px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
color: #d2d2d2;
|
||||
font-size: 0.95rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.component-row.selected {
|
||||
background: #3d5770;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.component-row.child {
|
||||
padding-left: 36px;
|
||||
background: #151515;
|
||||
color: #c9c9c9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.component-row.child.selected {
|
||||
background: #3d5770;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.component-row:hover:not(.selected) {
|
||||
background: rgba(92, 105, 116, 0.22);
|
||||
}
|
||||
|
||||
.component-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.component-row a {
|
||||
margin-left: auto;
|
||||
color: #d9d9d9;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1fr) 34px;
|
||||
grid-template-columns: minmax(119px, 1fr) 29px 29px 29px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
gap: 7px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #141414;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.details-search {
|
||||
height: 31px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: 26px 1fr;
|
||||
grid-template-columns: 22px 1fr;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
padding: 0 7px;
|
||||
color: #cfcfcf;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #343434;
|
||||
@@ -378,12 +474,12 @@ input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: 17px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.details-tool {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
@@ -403,27 +499,56 @@ input {
|
||||
}
|
||||
|
||||
.detail-category h3 {
|
||||
height: 32px;
|
||||
height: 27px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
padding: 0 9px;
|
||||
color: #d0d0d0;
|
||||
background: #303030;
|
||||
border-top: 1px solid #393939;
|
||||
border-bottom: 1px solid #171717;
|
||||
font-size: 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
min-height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.45fr) minmax(220px, 0.55fr);
|
||||
.transform-section {
|
||||
border-bottom: 1px solid #171717;
|
||||
}
|
||||
|
||||
.transform-section h3 {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
min-height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(153px, 0.48fr) minmax(187px, 0.52fr);
|
||||
border-bottom: 1px solid #171717;
|
||||
}
|
||||
|
||||
.detail-row.dirty .detail-name {
|
||||
box-shadow: inset 3px 0 0 #d6a23a;
|
||||
}
|
||||
|
||||
.detail-row.saving .detail-name {
|
||||
box-shadow: inset 3px 0 0 #2d86d8;
|
||||
}
|
||||
|
||||
.detail-row.error .detail-name {
|
||||
color: #ffb3aa;
|
||||
box-shadow: inset 3px 0 0 #d74630;
|
||||
}
|
||||
|
||||
.detail-row.external .detail-name {
|
||||
box-shadow: inset 3px 0 0 #5bbf70;
|
||||
}
|
||||
|
||||
.detail-row.read-only {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.detail-name,
|
||||
.detail-value {
|
||||
min-width: 0;
|
||||
@@ -432,18 +557,199 @@ input {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 14px;
|
||||
font-size: 16px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
color: #c8c8c8;
|
||||
border-right: 1px solid #151515;
|
||||
background: #242424;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #e0e0e0;
|
||||
background: #242424;
|
||||
gap: 7px;
|
||||
padding: 4px 9px 4px 12px;
|
||||
}
|
||||
|
||||
.text-value {
|
||||
min-width: 0;
|
||||
width: min(323px, 100%);
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
color: #e5e5e5;
|
||||
background: #101010;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
}
|
||||
|
||||
input.text-value {
|
||||
display: block;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.text-value:disabled,
|
||||
.axis:disabled,
|
||||
.checkbox-value:disabled {
|
||||
color: #858585;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.text-value:focus,
|
||||
.axis:focus {
|
||||
border-color: #2d86d8;
|
||||
}
|
||||
|
||||
.readonly-value {
|
||||
color: #a4a4a4;
|
||||
}
|
||||
|
||||
.text-value span,
|
||||
.asset-control span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vector-value {
|
||||
min-width: 0;
|
||||
width: min(357px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(54px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.transform-vector {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.axis {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 7px;
|
||||
color: #f0f0f0;
|
||||
background: #101010;
|
||||
border: 1px solid #333;
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
white-space: nowrap;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.axis-0 {
|
||||
border-left-color: #df3b1f;
|
||||
}
|
||||
|
||||
.axis-1 {
|
||||
border-left-color: #76b900;
|
||||
}
|
||||
|
||||
.axis-2 {
|
||||
border-left-color: #0d7fe8;
|
||||
}
|
||||
|
||||
.checkbox-value {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #353535;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checkbox-value.checked::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 11px;
|
||||
height: 6px;
|
||||
margin: 6px 0 0 4px;
|
||||
border-left: 2px solid #d7d7d7;
|
||||
border-bottom: 2px solid #d7d7d7;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.asset-value {
|
||||
min-width: 0;
|
||||
width: min(366px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(102px, 1fr);
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #bdbdbd;
|
||||
background:
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%),
|
||||
#101010;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid #313131;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 -3px 0 #16bfc4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-control {
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 17px;
|
||||
align-items: center;
|
||||
padding: 0 7px 0 9px;
|
||||
color: #e5e5e5;
|
||||
background: #101010;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 0 0 1px #050505;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: #bcbcbc;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: #343434;
|
||||
}
|
||||
|
||||
.row-reset {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
@@ -475,11 +781,18 @@ input {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
border: 0;
|
||||
grid-template-rows: 44px 52px 34px 1fr 44px;
|
||||
grid-template-rows: 37px 44px 29px 1fr 37px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-rows: minmax(170px, 1fr) minmax(260px, 0.95fr);
|
||||
grid-template-rows:
|
||||
minmax(120px, calc(var(--outliner-ratio) * 100%))
|
||||
10px
|
||||
minmax(220px, calc(var(--details-ratio) * 100%));
|
||||
}
|
||||
|
||||
.pane-splitter {
|
||||
min-height: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
|
||||
88
src/types.ts
Normal file
88
src/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type RemoteObject = Record<string, unknown>;
|
||||
|
||||
export type RemoteTransport = "WebSocket" | "HTTP";
|
||||
|
||||
export type DetailControlKind = "readonly" | "boolean" | "number" | "string" | "vector" | "rotator" | "asset";
|
||||
|
||||
export type DetailRowStatus = "clean" | "dirty" | "saving" | "error" | "external";
|
||||
|
||||
export type TransformVector = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
export type TransformTargetKind = "actor" | "component";
|
||||
|
||||
export type ObjectTransform = {
|
||||
objectPath: string;
|
||||
targetKind: TransformTargetKind;
|
||||
location: TransformVector;
|
||||
rotation: TransformVector;
|
||||
scale: TransformVector;
|
||||
readStatus: Record<TransformField, boolean>;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export type TransformField = "location" | "rotation" | "scale";
|
||||
|
||||
export type ActorRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
level: string;
|
||||
idName: string;
|
||||
folderPath: string;
|
||||
objectPath: string;
|
||||
};
|
||||
|
||||
export type ActorReference = {
|
||||
raw: unknown;
|
||||
objectPath: string;
|
||||
};
|
||||
|
||||
export type TreeNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
level: string;
|
||||
idName: string;
|
||||
objectPath?: string;
|
||||
kind: "world" | "folder" | "actor";
|
||||
children: TreeNode[];
|
||||
};
|
||||
|
||||
export type DetailProperty = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
type: string;
|
||||
value: unknown;
|
||||
description: string;
|
||||
access: "READ_ACCESS" | "WRITE_TRANSACTION_ACCESS";
|
||||
editable: boolean;
|
||||
controlKind: DetailControlKind;
|
||||
propertyPath: string;
|
||||
sourceObjectPath: string;
|
||||
writeValue: unknown;
|
||||
};
|
||||
|
||||
export type ObjectDetails = {
|
||||
objectPath: string;
|
||||
name: string;
|
||||
className: string;
|
||||
properties: DetailProperty[];
|
||||
};
|
||||
|
||||
export type ObjectComponent = {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
objectPath: string;
|
||||
};
|
||||
|
||||
export type CachedObjectDetails = {
|
||||
objectPath: string;
|
||||
savedAt: number;
|
||||
details: ObjectDetails;
|
||||
};
|
||||
Reference in New Issue
Block a user