transform values
This commit is contained in:
@@ -40,6 +40,10 @@ Details payloads are cached in browser `localStorage` for five minutes per objec
|
|||||||
|
|
||||||
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.
|
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
|
## Run
|
||||||
|
|
||||||
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
|
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
|
||||||
|
|||||||
248
src/App.tsx
248
src/App.tsx
@@ -3,12 +3,18 @@ import { ChevronDown, ChevronRight, Eye, Layers, Mountain, PackagePlus, RefreshC
|
|||||||
import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types";
|
import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types";
|
||||||
import { DetailsPanel } from "./components/DetailsPanel";
|
import { DetailsPanel } from "./components/DetailsPanel";
|
||||||
import { OutlinerRow } from "./components/OutlinerRow";
|
import { OutlinerRow } from "./components/OutlinerRow";
|
||||||
|
import { useDetailsEditing } from "./hooks/useDetailsEditing";
|
||||||
|
import { useTransformEditing } from "./hooks/useTransformEditing";
|
||||||
import { extractObjectPath, extractReturnValue } from "./lib/remoteValues";
|
import { extractObjectPath, extractReturnValue } from "./lib/remoteValues";
|
||||||
import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree";
|
import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree";
|
||||||
import { normalizeDetails } from "./services/details";
|
import { normalizeDetails } from "./services/details";
|
||||||
|
import { applyCachedActor } from "./services/actorCache";
|
||||||
import { readCachedDetails, writeCachedDetails } from "./services/detailsCache";
|
import { readCachedDetails, writeCachedDetails } from "./services/detailsCache";
|
||||||
|
import { discoverComponents } from "./services/components";
|
||||||
import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl";
|
import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl";
|
||||||
import { enrichActors, getAllLevelActors, normalizeActor } from "./services/unrealActors";
|
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 SPLIT_STORAGE_KEY = "unreal-outliner.split-ratio";
|
||||||
const DEFAULT_SPLIT_RATIO = 0.52;
|
const DEFAULT_SPLIT_RATIO = 0.52;
|
||||||
@@ -21,6 +27,19 @@ function readSavedSplitRatio(): number {
|
|||||||
return Number.isFinite(value) ? Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, value)) : 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() {
|
export function App() {
|
||||||
const [actors, setActors] = React.useState<ActorRow[]>([]);
|
const [actors, setActors] = React.useState<ActorRow[]>([]);
|
||||||
const [query, setQuery] = React.useState("");
|
const [query, setQuery] = React.useState("");
|
||||||
@@ -34,69 +53,174 @@ export function App() {
|
|||||||
const [detailsError, setDetailsError] = React.useState<string | null>(null);
|
const [detailsError, setDetailsError] = React.useState<string | null>(null);
|
||||||
const [isDetailsLoading, setIsDetailsLoading] = React.useState(false);
|
const [isDetailsLoading, setIsDetailsLoading] = React.useState(false);
|
||||||
const [splitRatio, setSplitRatio] = React.useState(readSavedSplitRatio);
|
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 mainContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const selectedObjectPathRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const tree = React.useMemo(() => buildTree(actors), [actors]);
|
const tree = React.useMemo(() => buildTree(actors), [actors]);
|
||||||
const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]);
|
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 refresh = React.useCallback(async () => {
|
const enrichActorsInBackground = React.useCallback(async (rows: ActorRow[]) => {
|
||||||
setIsLoading(true);
|
for (let index = 0; index < rows.length; index += 4) {
|
||||||
setError(null);
|
const enrichedBatch = await Promise.all(rows.slice(index, index + 4).map(enrichActor));
|
||||||
setStatus("Loading actors...");
|
setActors((currentActors) => {
|
||||||
try {
|
const byId = new Map(enrichedBatch.map((actor) => [actor.id, actor]));
|
||||||
const payload = await getAllLevelActors();
|
return currentActors.map((actor) => byId.get(actor.id) ?? actor);
|
||||||
const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) }));
|
});
|
||||||
const rows = await enrichActors(actorRefs.map(({ raw }, index) => normalizeActor(raw, index)));
|
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||||
setTransport(getActiveTransport());
|
|
||||||
setActors(rows);
|
|
||||||
setExpanded((current) => new Set([...current, "world"]));
|
|
||||||
setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`);
|
|
||||||
} catch (reason) {
|
|
||||||
const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control";
|
|
||||||
setError(message);
|
|
||||||
setStatus("Connection failed");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
React.useEffect(() => {
|
||||||
refresh();
|
void loadActors(false);
|
||||||
}, [refresh]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
if (!selectedNode?.objectPath) {
|
if (!selectedObjectPath) {
|
||||||
setDetails(null);
|
setDetails(null);
|
||||||
|
setTransform(null);
|
||||||
setDetailsError(null);
|
setDetailsError(null);
|
||||||
setIsDetailsLoading(false);
|
setIsDetailsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectPath = selectedNode.objectPath;
|
const targetKind = selectedTransformTargetKind(selectedObjectPath);
|
||||||
const cachedDetails = readCachedDetails(objectPath);
|
setTransform((current) => (current?.objectPath === selectedObjectPath ? current : createDefaultTransform(selectedObjectPath, targetKind)));
|
||||||
if (cachedDetails) {
|
|
||||||
setDetails(cachedDetails);
|
|
||||||
setIsDetailsLoading(false);
|
|
||||||
} else {
|
|
||||||
setDetails(null);
|
|
||||||
setIsDetailsLoading(true);
|
|
||||||
}
|
|
||||||
setDetailsError(null);
|
|
||||||
|
|
||||||
Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)])
|
const previousTransform = transform;
|
||||||
.then(([description, properties]) => {
|
Promise.all([loadDetails(selectedObjectPath, true), readObjectTransform(selectedObjectPath, targetKind)])
|
||||||
|
.then(([, nextTransform]) => {
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
const freshDetails = normalizeDetails(description, properties);
|
setTransform((current) => mergeTransformFallback(nextTransform, current ?? previousTransform));
|
||||||
writeCachedDetails(objectPath, freshDetails);
|
|
||||||
setDetails(freshDetails);
|
|
||||||
setTransport(getActiveTransport());
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
setDetails(null);
|
setDetails(null);
|
||||||
|
setTransform(null);
|
||||||
setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties");
|
setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -109,7 +233,38 @@ export function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
};
|
};
|
||||||
}, [selectedNode?.objectPath]);
|
}, [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) => {
|
const toggleNode = (node: TreeNode) => {
|
||||||
setExpanded((current) => {
|
setExpanded((current) => {
|
||||||
@@ -268,7 +423,20 @@ export function App() {
|
|||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailsPanel node={selectedNode} details={details} isLoading={isDetailsLoading} error={detailsError} />
|
<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>
|
</div>
|
||||||
|
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react";
|
import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react";
|
||||||
import type { DetailProperty, ObjectDetails, TreeNode } from "../types";
|
import type {
|
||||||
|
DetailProperty,
|
||||||
|
DetailRowStatus,
|
||||||
|
ObjectComponent,
|
||||||
|
ObjectDetails,
|
||||||
|
ObjectTransform,
|
||||||
|
TransformField,
|
||||||
|
TransformVector,
|
||||||
|
TreeNode,
|
||||||
|
} from "../types";
|
||||||
import { basename } from "../lib/remoteValues";
|
import { basename } from "../lib/remoteValues";
|
||||||
import {
|
import {
|
||||||
detailAssetPath,
|
detailAssetPath,
|
||||||
@@ -9,9 +18,11 @@ import {
|
|||||||
isVectorLikeProperty,
|
isVectorLikeProperty,
|
||||||
normalizeThumbnailPayload,
|
normalizeThumbnailPayload,
|
||||||
vectorParts,
|
vectorParts,
|
||||||
|
withUpdatedVectorPart,
|
||||||
} from "../services/details";
|
} from "../services/details";
|
||||||
import { readRemoteObjectThumbnail } from "../services/remoteControl";
|
import { readRemoteObjectThumbnail } from "../services/remoteControl";
|
||||||
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
|
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
|
||||||
|
import { detailPropertyKey } from "../hooks/useDetailsEditing";
|
||||||
import { NodeIcon } from "./NodeIcon";
|
import { NodeIcon } from "./NodeIcon";
|
||||||
|
|
||||||
const emptyNode: TreeNode = {
|
const emptyNode: TreeNode = {
|
||||||
@@ -24,9 +35,250 @@ const emptyNode: TreeNode = {
|
|||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryTabs = ["General", "Actor", "LOD", "Misc", "Physics", "Rendering", "Streaming", "All"];
|
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));
|
||||||
|
|
||||||
function DetailValue({ property }: { property: DetailProperty }) {
|
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 assetPath = detailAssetPath(property);
|
||||||
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
|
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
|
||||||
|
|
||||||
@@ -76,23 +328,44 @@ function DetailValue({ property }: { property: DetailProperty }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isVectorLikeProperty(property)) {
|
if (isVectorLikeProperty(property)) {
|
||||||
return (
|
return <VectorInput property={property} status={status} onEdit={onEdit} />;
|
||||||
<div className="vector-value">
|
|
||||||
{vectorParts(property.value).map((part, index) => (
|
|
||||||
<span className={`axis axis-${index}`} key={part.label}>
|
|
||||||
{part.value}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBooleanProperty(property)) {
|
if (isBooleanProperty(property)) {
|
||||||
return <span className={`checkbox-value ${property.value ? "checked" : ""}`} />;
|
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 (
|
return (
|
||||||
<div className="text-value">
|
<div className={`text-value ${property.editable ? "" : "readonly-value"}`}>
|
||||||
<span>{formatDetailValue(property.value)}</span>
|
<span>{formatDetailValue(property.value)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -101,22 +374,35 @@ function DetailValue({ property }: { property: DetailProperty }) {
|
|||||||
export function DetailsPanel({
|
export function DetailsPanel({
|
||||||
node,
|
node,
|
||||||
details,
|
details,
|
||||||
|
components,
|
||||||
|
selectedObjectPath,
|
||||||
|
transform,
|
||||||
|
transformRowStates,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
rowStates,
|
||||||
|
onEditProperty,
|
||||||
|
onEditTransform,
|
||||||
|
onSelectObjectPath,
|
||||||
}: {
|
}: {
|
||||||
node: TreeNode | null;
|
node: TreeNode | null;
|
||||||
details: ObjectDetails | null;
|
details: ObjectDetails | null;
|
||||||
|
components: ObjectComponent[];
|
||||||
|
selectedObjectPath: string | null;
|
||||||
|
transform: ObjectTransform | null;
|
||||||
|
transformRowStates: Record<TransformField, { status: DetailRowStatus; error?: string }>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
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 [query, setQuery] = React.useState("");
|
||||||
const [activeCategory, setActiveCategory] = React.useState("All");
|
|
||||||
|
|
||||||
const groupedProperties = React.useMemo(() => {
|
const groupedProperties = React.useMemo(() => {
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
const visibleProperties = (details?.properties ?? []).filter((property) => {
|
const visibleProperties = (details?.properties ?? []).filter((property) => {
|
||||||
const matchesCategory = activeCategory === "All" || property.category.toLowerCase().includes(activeCategory.toLowerCase());
|
|
||||||
if (!matchesCategory) return false;
|
|
||||||
if (!lowerQuery) return true;
|
if (!lowerQuery) return true;
|
||||||
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
|
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
|
||||||
field.toLowerCase().includes(lowerQuery),
|
field.toLowerCase().includes(lowerQuery),
|
||||||
@@ -129,7 +415,7 @@ export function DetailsPanel({
|
|||||||
groups[category].push(property);
|
groups[category].push(property);
|
||||||
return groups;
|
return groups;
|
||||||
}, {});
|
}, {});
|
||||||
}, [activeCategory, details, query]);
|
}, [details, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="details-panel" aria-label="Details">
|
<aside className="details-panel" aria-label="Details">
|
||||||
@@ -153,16 +439,29 @@ export function DetailsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{node?.objectPath ? (
|
||||||
<div className="component-list">
|
<div className="component-list">
|
||||||
<div className="component-row selected">
|
<button
|
||||||
|
className={`component-row ${selectedObjectPath === node.objectPath ? "selected" : ""}`}
|
||||||
|
onClick={() => onSelectObjectPath(node.objectPath!)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<NodeIcon node={node ?? emptyNode} />
|
<NodeIcon node={node ?? emptyNode} />
|
||||||
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
|
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
|
||||||
</div>
|
</button>
|
||||||
<div className="component-row child">
|
{components.map((component) => (
|
||||||
|
<button
|
||||||
|
className={`component-row child ${selectedObjectPath === component.objectPath ? "selected" : ""}`}
|
||||||
|
key={component.id}
|
||||||
|
onClick={() => onSelectObjectPath(component.objectPath)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<Box size={18} />
|
<Box size={18} />
|
||||||
<span>{node?.type ? `${node.type}Component (${node.type}Component0)` : "Component"}</span>
|
<span>{component.label}</span>
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="details-search-row">
|
<div className="details-search-row">
|
||||||
<label className="details-search">
|
<label className="details-search">
|
||||||
@@ -180,40 +479,43 @@ export function DetailsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="category-tabs">
|
|
||||||
{categoryTabs.map((category) => (
|
|
||||||
<button
|
|
||||||
className={activeCategory === category ? "active" : ""}
|
|
||||||
key={category}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveCategory(category)}
|
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="details-categories">
|
<div className="details-categories">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
|
||||||
<div className="details-empty">Loading properties...</div>
|
<div className="details-empty">Loading properties...</div>
|
||||||
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="details-empty">{error}</div>
|
<div className="details-empty">{error}</div>
|
||||||
) : !node?.objectPath ? (
|
) : !node?.objectPath ? (
|
||||||
<div className="details-empty">Select an actor to inspect exposed properties.</div>
|
<div className="details-empty">Select an actor to inspect exposed properties.</div>
|
||||||
) : Object.keys(groupedProperties).length === 0 ? (
|
) : Object.keys(groupedProperties).length === 0 ? (
|
||||||
<div className="details-empty">No readable properties found.</div>
|
<>
|
||||||
|
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
|
||||||
|
{!transform ? <div className="details-empty">No readable properties found.</div> : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(groupedProperties).map(([category, properties]) => (
|
<>
|
||||||
|
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
|
||||||
|
{Object.entries(groupedProperties).map(([category, properties]) => (
|
||||||
<section className="detail-category" key={category}>
|
<section className="detail-category" key={category}>
|
||||||
<h3>
|
<h3>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
{category}
|
{category}
|
||||||
</h3>
|
</h3>
|
||||||
{properties.map((property) => (
|
{properties.map((property) => (
|
||||||
<div className="detail-row" key={property.name} title={property.description || property.type}>
|
<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-name">{property.displayName}</span>
|
||||||
<span className="detail-value">
|
<span className="detail-value">
|
||||||
<DetailValue property={property} />
|
<DetailValue
|
||||||
|
property={property}
|
||||||
|
status={rowStates[detailPropertyKey(property)]?.status ?? "clean"}
|
||||||
|
onEdit={onEditProperty}
|
||||||
|
/>
|
||||||
<button className="reset-button row-reset" type="button" title="Reset to default">
|
<button className="reset-button row-reset" type="button" title="Reset to default">
|
||||||
<RotateCcw size={15} />
|
<RotateCcw size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -221,7 +523,8 @@ export function DetailsPanel({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
))
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DetailProperty, ObjectDetails, RemoteObject } from "../types";
|
import type { DetailControlKind, DetailProperty, ObjectDetails, RemoteObject } from "../types";
|
||||||
import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues";
|
import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues";
|
||||||
|
|
||||||
function getMetadataString(property: RemoteObject, key: string): string {
|
function getMetadataString(property: RemoteObject, key: string): string {
|
||||||
@@ -44,7 +44,52 @@ function extractPropertyValues(payload: unknown): RemoteObject {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDetails(describePayload: unknown, propertyPayload: unknown): ObjectDetails {
|
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 description = asRecord(describePayload);
|
||||||
const values = extractPropertyValues(propertyPayload);
|
const values = extractPropertyValues(propertyPayload);
|
||||||
const properties = Array.isArray(description.Properties)
|
const properties = Array.isArray(description.Properties)
|
||||||
@@ -54,18 +99,29 @@ export function normalizeDetails(describePayload: unknown, propertyPayload: unkn
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
objectPath,
|
||||||
name: firstString(description, ["Name", "name"], "Object"),
|
name: firstString(description, ["Name", "name"], "Object"),
|
||||||
className: firstString(description, ["Class", "class"], "UObject"),
|
className: firstString(description, ["Class", "class"], "UObject"),
|
||||||
properties: properties.map((rawProperty): DetailProperty => {
|
properties: properties.map((rawProperty): DetailProperty => {
|
||||||
const property = asRecord(rawProperty);
|
const property = asRecord(rawProperty);
|
||||||
const name = firstString(property, ["Name", "name"], "Property");
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name),
|
displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name),
|
||||||
category: getMetadataString(property, "Category") || "General",
|
category: getMetadataString(property, "Category") || "General",
|
||||||
type: firstString(property, ["Type", "type"], "Property"),
|
type,
|
||||||
value: findPropertyValue(values, name),
|
value,
|
||||||
description: firstString(property, ["Description", "description"]),
|
description: firstString(property, ["Description", "description"]),
|
||||||
|
access: editable ? "WRITE_TRANSACTION_ACCESS" : "READ_ACCESS",
|
||||||
|
editable,
|
||||||
|
controlKind,
|
||||||
|
propertyPath: name,
|
||||||
|
sourceObjectPath: objectPath,
|
||||||
|
writeValue: value,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -128,12 +184,7 @@ export function isBooleanProperty(property: DetailProperty): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isVectorLikeProperty(property: DetailProperty): boolean {
|
export function isVectorLikeProperty(property: DetailProperty): boolean {
|
||||||
if (!property.value || typeof property.value !== "object" || Array.isArray(property.value)) {
|
return isVectorLikeValue(property.value);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = asRecord(property.value);
|
|
||||||
return ["X", "Y", "Z"].every((key) => key in record) || ["Roll", "Pitch", "Yaw"].every((key) => key in record);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function vectorParts(value: unknown): Array<{ label: string; value: string }> {
|
export function vectorParts(value: unknown): Array<{ label: string; value: string }> {
|
||||||
@@ -155,3 +206,29 @@ export function normalizeThumbnailPayload(payload: unknown): string {
|
|||||||
|
|
||||||
return value.startsWith("data:") ? value : `data:image/png;base64,${value}`;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ export function writeCachedDetails(objectPath: string, details: ObjectDetails):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function pruneDetailsCache(): void {
|
||||||
const entries: Array<{ key: string; savedAt: number }> = [];
|
const entries: Array<{ key: string; savedAt: number }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,17 @@ export async function readRemoteObjectProperties(objectPath: string): Promise<un
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
|
||||||
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
|
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
|
||||||
}
|
}
|
||||||
|
|||||||
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.`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
nestedString,
|
nestedString,
|
||||||
} from "../lib/remoteValues";
|
} from "../lib/remoteValues";
|
||||||
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
|
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
|
||||||
|
import { writeCachedActor } from "./actorCache";
|
||||||
|
|
||||||
const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem";
|
const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem";
|
||||||
const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary";
|
const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary";
|
||||||
@@ -101,7 +102,7 @@ async function tryRemoteString(objectPath: string, functionName: string): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichActor(actor: ActorRow): Promise<ActorRow> {
|
export async function enrichActor(actor: ActorRow): Promise<ActorRow> {
|
||||||
if (!actor.objectPath) {
|
if (!actor.objectPath) {
|
||||||
return actor;
|
return actor;
|
||||||
}
|
}
|
||||||
@@ -113,12 +114,15 @@ async function enrichActor(actor: ActorRow): Promise<ActorRow> {
|
|||||||
|
|
||||||
const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : "";
|
const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : "";
|
||||||
|
|
||||||
return mergeActorDetails(actor, {
|
const enrichedActor = mergeActorDetails(actor, {
|
||||||
label: label || actor.label,
|
label: label || actor.label,
|
||||||
folderPath: folderPath || actor.folderPath,
|
folderPath: folderPath || actor.folderPath,
|
||||||
level: deriveLevelNameFromPath(actor.objectPath) || actor.level,
|
level: deriveLevelNameFromPath(actor.objectPath) || actor.level,
|
||||||
type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type,
|
type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
writeCachedActor(enrichedActor);
|
||||||
|
return enrichedActor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichActors(actors: ActorRow[]): Promise<ActorRow[]> {
|
export async function enrichActors(actors: ActorRow[]): Promise<ActorRow[]> {
|
||||||
|
|||||||
104
src/styles.css
104
src/styles.css
@@ -316,7 +316,7 @@ input {
|
|||||||
.details-panel {
|
.details-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 32px 41px 100px 37px 34px 1fr;
|
grid-template-rows: 32px 41px 100px 37px 1fr;
|
||||||
border-top: 2px solid #0d0d0d;
|
border-top: 2px solid #0d0d0d;
|
||||||
background: #202020;
|
background: #202020;
|
||||||
color: #cfcfcf;
|
color: #cfcfcf;
|
||||||
@@ -402,6 +402,11 @@ input {
|
|||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
color: #d2d2d2;
|
color: #d2d2d2;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.component-row.selected {
|
.component-row.selected {
|
||||||
@@ -416,6 +421,15 @@ input {
|
|||||||
font-size: 0.85rem;
|
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 {
|
.component-row span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -478,38 +492,6 @@ input {
|
|||||||
background: #363636;
|
background: #363636;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #262626;
|
|
||||||
border-bottom: 1px solid #141414;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tabs button {
|
|
||||||
height: 25px;
|
|
||||||
min-width: 71px;
|
|
||||||
padding: 0 12px;
|
|
||||||
color: #c7c7c7;
|
|
||||||
background: #2c2c2c;
|
|
||||||
border: 1px solid #151515;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tabs button:hover {
|
|
||||||
background: #343434;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tabs button.active {
|
|
||||||
color: #ffffff;
|
|
||||||
background: #0877d9;
|
|
||||||
border-color: #0a5ea9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-categories {
|
.details-categories {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -531,6 +513,14 @@ input {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transform-section {
|
||||||
|
border-bottom: 1px solid #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-section h3 {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -538,6 +528,27 @@ input {
|
|||||||
border-bottom: 1px solid #171717;
|
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-name,
|
||||||
.detail-value {
|
.detail-value {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -577,6 +588,27 @@ input {
|
|||||||
box-shadow: inset 0 0 0 1px #050505;
|
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,
|
.text-value span,
|
||||||
.asset-control span {
|
.asset-control span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -593,6 +625,10 @@ input {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transform-vector {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.axis {
|
.axis {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -607,6 +643,7 @@ input {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: inset 0 0 0 1px #050505;
|
box-shadow: inset 0 0 0 1px #050505;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis-0 {
|
.axis-0 {
|
||||||
@@ -628,6 +665,7 @@ input {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #111;
|
background: #111;
|
||||||
box-shadow: inset 0 0 0 1px #050505;
|
box-shadow: inset 0 0 0 1px #050505;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-value.checked::after {
|
.checkbox-value.checked::after {
|
||||||
|
|||||||
38
src/types.ts
38
src/types.ts
@@ -2,6 +2,30 @@ export type RemoteObject = Record<string, unknown>;
|
|||||||
|
|
||||||
export type RemoteTransport = "WebSocket" | "HTTP";
|
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 = {
|
export type ActorRow = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -35,14 +59,28 @@ export type DetailProperty = {
|
|||||||
type: string;
|
type: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
description: string;
|
description: string;
|
||||||
|
access: "READ_ACCESS" | "WRITE_TRANSACTION_ACCESS";
|
||||||
|
editable: boolean;
|
||||||
|
controlKind: DetailControlKind;
|
||||||
|
propertyPath: string;
|
||||||
|
sourceObjectPath: string;
|
||||||
|
writeValue: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ObjectDetails = {
|
export type ObjectDetails = {
|
||||||
|
objectPath: string;
|
||||||
name: string;
|
name: string;
|
||||||
className: string;
|
className: string;
|
||||||
properties: DetailProperty[];
|
properties: DetailProperty[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ObjectComponent = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
objectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CachedObjectDetails = {
|
export type CachedObjectDetails = {
|
||||||
objectPath: string;
|
objectPath: string;
|
||||||
savedAt: number;
|
savedAt: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user