1
0

Aiden added closer matching look

This commit is contained in:
2026-05-19 16:23:51 +10:00
parent 676290faf7
commit bdf5f4b44e
6 changed files with 522 additions and 9 deletions

View File

@@ -38,6 +38,8 @@ The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unr
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.
## Run
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.

View File

@@ -1,8 +1,17 @@
import React from "react";
import { ChevronDown, Search, Settings, SlidersHorizontal, 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 { basename } from "../lib/remoteValues";
import { formatDetailValue } from "../services/details";
import {
detailAssetPath,
formatDetailValue,
isBooleanProperty,
isVectorLikeProperty,
normalizeThumbnailPayload,
vectorParts,
} from "../services/details";
import { readRemoteObjectThumbnail } from "../services/remoteControl";
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
import { NodeIcon } from "./NodeIcon";
const emptyNode: TreeNode = {
@@ -15,6 +24,80 @@ const emptyNode: TreeNode = {
children: [],
};
const categoryTabs = ["General", "Actor", "LOD", "Misc", "Physics", "Rendering", "Streaming", "All"];
function DetailValue({ property }: { property: DetailProperty }) {
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 (
<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)) {
return <span className={`checkbox-value ${property.value ? "checked" : ""}`} />;
}
return (
<div className="text-value">
<span>{formatDetailValue(property.value)}</span>
</div>
);
}
export function DetailsPanel({
node,
details,
@@ -27,10 +110,13 @@ export function DetailsPanel({
error: string | null;
}) {
const [query, setQuery] = React.useState("");
const [activeCategory, setActiveCategory] = React.useState("All");
const groupedProperties = React.useMemo(() => {
const lowerQuery = query.toLowerCase();
const visibleProperties = (details?.properties ?? []).filter((property) => {
const matchesCategory = activeCategory === "All" || property.category.toLowerCase().includes(activeCategory.toLowerCase());
if (!matchesCategory) return false;
if (!lowerQuery) return true;
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
field.toLowerCase().includes(lowerQuery),
@@ -43,7 +129,7 @@ export function DetailsPanel({
groups[category].push(property);
return groups;
}, {});
}, [details, query]);
}, [activeCategory, details, query]);
return (
<aside className="details-panel" aria-label="Details">
@@ -67,16 +153,47 @@ export function DetailsPanel({
</div>
</div>
<div className="component-list">
<div className="component-row selected">
<NodeIcon node={node ?? emptyNode} />
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
</div>
<div className="component-row child">
<Box size={18} />
<span>{node?.type ? `${node.type}Component (${node.type}Component0)` : "Component"}</span>
<a>Edit in C++</a>
</div>
</div>
<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="category-tabs">
{categoryTabs.map((category) => (
<button
className={activeCategory === category ? "active" : ""}
key={category}
type="button"
onClick={() => setActiveCategory(category)}
>
{category}
</button>
))}
</div>
<div className="details-categories">
{isLoading ? (
<div className="details-empty">Loading properties...</div>
@@ -96,7 +213,12 @@ export function DetailsPanel({
{properties.map((property) => (
<div className="detail-row" key={property.name} title={property.description || property.type}>
<span className="detail-name">{property.displayName}</span>
<span className="detail-value">{formatDetailValue(property.value)}</span>
<span className="detail-value">
<DetailValue property={property} />
<button className="reset-button row-reset" type="button" title="Reset to default">
<RotateCcw size={15} />
</button>
</span>
</div>
))}
</section>

View File

@@ -109,3 +109,49 @@ export function formatDetailValue(value: unknown): string {
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 {
if (!property.value || typeof property.value !== "object" || Array.isArray(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 }> {
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}`;
}

View File

@@ -223,3 +223,7 @@ export async function readRemoteObjectProperties(objectPath: string): Promise<un
access: "READ_ACCESS",
});
}
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
}

View 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));
}

View File

@@ -281,7 +281,7 @@ input {
.details-panel {
min-height: 0;
display: grid;
grid-template-rows: 38px 58px 44px 1fr;
grid-template-rows: 38px 48px 118px 44px 40px 1fr;
border-top: 2px solid #0d0d0d;
background: #202020;
color: #cfcfcf;
@@ -323,7 +323,7 @@ input {
grid-template-columns: 28px 1fr;
align-items: center;
gap: 10px;
padding: 9px 14px;
padding: 7px 14px;
border-bottom: 1px solid #171717;
background: #242424;
}
@@ -339,7 +339,7 @@ input {
.details-object strong {
color: #f1f1f1;
font-size: 18px;
font-size: 17px;
font-weight: 500;
}
@@ -349,9 +349,56 @@ input {
font-size: 14px;
}
.component-list {
min-height: 0;
padding: 6px 8px 8px;
background: #181818;
border: 1px solid #242424;
border-left: 0;
border-right: 0;
overflow: hidden;
}
.component-row {
height: 34px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #d2d2d2;
font-size: 18px;
}
.component-row.selected {
background: #3d5770;
color: #f1f1f1;
}
.component-row.child {
padding-left: 42px;
background: #151515;
color: #c9c9c9;
font-size: 16px;
}
.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(140px, 1fr) 34px 34px 34px;
align-items: center;
gap: 8px;
padding: 6px 12px;
@@ -396,6 +443,38 @@ input {
background: #363636;
}
.category-tabs {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
background: #262626;
border-bottom: 1px solid #141414;
overflow-x: auto;
}
.category-tabs button {
height: 29px;
min-width: 84px;
padding: 0 14px;
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 {
min-height: 0;
overflow: auto;
@@ -420,7 +499,7 @@ input {
.detail-row {
min-height: 40px;
display: grid;
grid-template-columns: minmax(180px, 0.45fr) minmax(220px, 0.55fr);
grid-template-columns: minmax(180px, 0.48fr) minmax(220px, 0.52fr);
border-bottom: 1px solid #171717;
}
@@ -439,11 +518,165 @@ input {
.detail-name {
color: #c8c8c8;
border-right: 1px solid #151515;
background: #242424;
}
.detail-value {
color: #e0e0e0;
background: #242424;
gap: 8px;
padding: 5px 10px 5px 14px;
}
.text-value {
min-width: 0;
width: min(380px, 100%);
height: 28px;
display: flex;
align-items: center;
padding: 0 10px;
color: #e5e5e5;
background: #101010;
border: 1px solid #333;
border-radius: 4px;
box-shadow: inset 0 0 0 1px #050505;
}
.text-value span,
.asset-control span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vector-value {
min-width: 0;
width: min(420px, 100%);
display: grid;
grid-template-columns: repeat(3, minmax(64px, 1fr));
gap: 5px;
}
.axis {
height: 28px;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
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;
}
.axis-0 {
border-left-color: #df3b1f;
}
.axis-1 {
border-left-color: #76b900;
}
.axis-2 {
border-left-color: #0d7fe8;
}
.checkbox-value {
width: 26px;
height: 26px;
border: 1px solid #353535;
border-radius: 4px;
background: #111;
box-shadow: inset 0 0 0 1px #050505;
}
.checkbox-value.checked::after {
content: "";
display: block;
width: 13px;
height: 7px;
margin: 7px 0 0 5px;
border-left: 2px solid #d7d7d7;
border-bottom: 2px solid #d7d7d7;
transform: rotate(-45deg);
}
.asset-value {
min-width: 0;
width: min(430px, 100%);
display: grid;
grid-template-columns: 54px minmax(120px, 1fr);
align-items: center;
gap: 8px;
}
.asset-thumb {
width: 54px;
height: 54px;
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: 31px;
display: grid;
grid-template-columns: minmax(0, 1fr) 20px;
align-items: center;
padding: 0 8px 0 10px;
color: #e5e5e5;
background: #101010;
border: 1px solid #333;
border-radius: 5px;
box-shadow: inset 0 0 0 1px #050505;
}
.reset-button {
width: 26px;
height: 26px;
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 {