1
0

Initial commit

This commit is contained in:
2026-05-19 16:09:55 +10:00
commit 13caeb482b
11 changed files with 3687 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.*
npm-debug.log*

80
README.md Normal file
View File

@@ -0,0 +1,80 @@
# Unreal Outliner Control
A Vite + React + TypeScript web app that mirrors the Unreal Editor Outliner for the currently open world.
The app prefers Unreal Remote Control's WebSocket server, then falls back to HTTP when the socket is unavailable.
By default it connects to:
```text
ws://127.0.0.1:30020
```
When WebSocket is available, the app sends Unreal's Remote Control `http` WebSocket message type and tunnels the same HTTP route:
```http
PUT /remote/object/call
```
The HTTP fallback request goes through Vite's dev proxy and targets `http://127.0.0.1:30010/remote/object/call` by default. Both transports call:
```json
{
"objectPath": "/Script/UnrealEd.Default__EditorActorSubsystem",
"functionName": "GetAllLevelActors",
"parameters": {},
"generateTransaction": false
}
```
Selecting an actor also loads a Details pane using:
```http
PUT /remote/object/describe
PUT /remote/object/property
```
The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unreal for all readable properties exposed on that UObject.
## Run
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
Then run:
```bash
npm install
npm run dev
```
Open:
```text
http://127.0.0.1:5173/
```
If your Unreal Remote Control HTTP server is on another URL:
```bash
set UNREAL_REMOTE_URL=http://127.0.0.1:30010
npm run dev
```
If your Unreal Remote Control WebSocket server is on another URL:
```bash
set VITE_UNREAL_WS_URL=ws://127.0.0.1:30020
npm run dev
```
## Useful UI Libraries
For an Unreal-like outliner, a custom tree/table gives the closest visual match because Unreal's Slate UI is not available as a web component.
Good libraries if the app grows:
- `react-arborist` for a performant tree with drag/drop and renaming.
- `@tanstack/react-table` plus `@tanstack/react-virtual` for a large outliner with resizable columns and virtualization.
- `ag-grid-community` if you want a full data-grid with tree data, sorting, filtering, and column tooling.
This first version keeps the UI custom so it can look closer to the Unreal Outliner screenshot.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unreal Outliner Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1869
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "unreal-outliner-control",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"lucide-react": "^0.544.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^5.9.2",
"vite": "^7.1.7"
}
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#171717"/>
<path d="M8 7h16v4H8zM8 14h11v4H8zM8 21h16v4H8z" fill="#d7d7d7"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

1135
src/main.tsx Normal file

File diff suppressed because it is too large Load Diff

521
src/styles.css Normal file
View File

@@ -0,0 +1,521 @@
:root {
color-scheme: dark;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: #101010;
color: #c7c7c7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #101010;
}
button,
input {
font: inherit;
}
.shell {
min-height: 100vh;
padding: 10px;
background: #101010;
}
.panel {
width: min(1220px, 100%);
min-height: calc(100vh - 20px);
height: calc(100vh - 20px);
display: grid;
grid-template-rows: 48px 56px 36px 1fr 50px;
border: 1px solid #0a0a0a;
background: #171717;
box-shadow: inset 0 0 0 1px #232323;
}
.tabs {
display: flex;
align-items: stretch;
height: 48px;
background: #1f1f1f;
border-bottom: 1px solid #0c0c0c;
}
.tab {
display: flex;
align-items: center;
gap: 10px;
min-width: 240px;
padding: 0 14px;
color: #bdbdbd;
font-size: 20px;
border-right: 1px solid #111;
}
.tab.active {
color: #f0f0f0;
background: #242424;
box-shadow: inset 0 3px 0 #2d6eaf;
}
.tab svg:last-child {
margin-left: auto;
}
.toolbar {
display: grid;
grid-template-columns: 36px 30px minmax(160px, 1fr) 36px 36px 36px;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: #262626;
}
.icon-button {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border: 0;
padding: 0;
color: #cfcfcf;
background: transparent;
border-radius: 3px;
cursor: pointer;
}
.icon-button:hover:not(:disabled) {
background: #363636;
}
.icon-button:disabled {
color: #777;
cursor: wait;
}
.search {
height: 36px;
min-width: 0;
display: grid;
grid-template-columns: 30px 1fr 24px;
align-items: center;
padding: 0 8px;
color: #cfcfcf;
background: #0d0d0d;
border: 1px solid #343434;
border-radius: 18px;
box-shadow: inset 0 0 0 1px #050505;
}
.search::after {
content: "";
width: 10px;
height: 10px;
border-right: 3px solid #cfcfcf;
border-bottom: 3px solid #cfcfcf;
transform: rotate(45deg) translateY(-3px);
justify-self: center;
}
.search input {
width: 100%;
min-width: 0;
color: #dadada;
background: transparent;
border: 0;
outline: 0;
font-size: 20px;
}
.search input::placeholder {
color: #666;
}
.columns {
display: grid;
grid-template-columns: 38px minmax(260px, 1.3fr) minmax(140px, 0.55fr) minmax(130px, 0.52fr) minmax(150px, 0.62fr);
align-items: center;
background: #303030;
color: #c7c7c7;
font-size: 20px;
}
.columns span {
height: 100%;
display: flex;
align-items: center;
padding: 0 8px;
border-right: 2px solid #1b1b1b;
}
.columns .visibility {
justify-content: center;
padding: 0;
}
.main-content {
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: minmax(180px, 1fr) minmax(250px, 0.9fr);
background: #171717;
}
.rows {
min-height: 0;
overflow: auto;
background: repeating-linear-gradient(#181818 0 30px, #141414 30px 60px);
}
.outliner-row {
width: 100%;
min-width: 760px;
height: 30px;
display: grid;
grid-template-columns: minmax(298px, 1.3fr) minmax(140px, 0.55fr) minmax(130px, 0.52fr) minmax(150px, 0.62fr);
align-items: center;
color: #bebebe;
background: transparent;
border: 0;
padding: 0;
text-align: left;
cursor: default;
}
.outliner-row.selected {
background: #2b4056;
color: #f0f0f0;
}
.outliner-row:hover:not(.selected) {
background: rgba(92, 105, 116, 0.24);
color: #d9d9d9;
}
.cell {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 12px;
font-size: 20px;
color: inherit;
}
.item-cell {
display: flex;
align-items: center;
gap: 5px;
padding-left: 42px;
}
.indent {
width: calc(var(--depth) * 18px);
flex: 0 0 auto;
}
.expander {
width: 18px;
height: 22px;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
color: #adadad;
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.type-cell,
.level-cell,
.id-cell {
color: #8f8f8f;
}
.folder-icon {
color: #d0a64f;
fill: #d0a64f;
}
.world-icon,
.actor-icon {
color: #c2c2c2;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 20px;
color: #cfcfcf;
background: #303030;
font-size: 20px;
}
.empty {
min-height: 220px;
display: grid;
align-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
color: #bdbdbd;
text-align: center;
font-size: 16px;
}
.empty strong {
color: #f0f0f0;
font-size: 22px;
}
.details-panel {
min-height: 0;
display: grid;
grid-template-rows: 38px 58px 44px 1fr;
border-top: 2px solid #0d0d0d;
background: #202020;
color: #cfcfcf;
}
.details-tabs {
display: flex;
align-items: stretch;
background: #151515;
}
.details-tab {
display: flex;
align-items: center;
gap: 9px;
min-width: 210px;
padding: 0 12px;
color: #c7c7c7;
font-size: 20px;
background: #232323;
border-right: 1px solid #101010;
}
.details-tab.muted {
color: #a8a8a8;
background: #181818;
}
.details-tab.active {
color: #dedede;
}
.details-tab svg:last-child {
margin-left: auto;
}
.details-object {
display: grid;
grid-template-columns: 28px 1fr;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid #171717;
background: #242424;
}
.details-object strong,
.details-object span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details-object strong {
color: #f1f1f1;
font-size: 18px;
font-weight: 500;
}
.details-object span {
margin-top: 2px;
color: #a8a8a8;
font-size: 14px;
}
.details-search-row {
display: grid;
grid-template-columns: minmax(140px, 1fr) 34px;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid #141414;
background: #262626;
}
.details-search {
height: 31px;
display: grid;
grid-template-columns: 26px 1fr;
align-items: center;
padding: 0 8px;
color: #cfcfcf;
background: #0d0d0d;
border: 1px solid #343434;
border-radius: 15px;
}
.details-search input {
min-width: 0;
width: 100%;
color: #d8d8d8;
background: transparent;
border: 0;
outline: 0;
font-size: 17px;
}
.details-tool {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 3px;
color: #c7c7c7;
background: transparent;
}
.details-tool:hover {
background: #363636;
}
.details-categories {
min-height: 0;
overflow: auto;
background: #1f1f1f;
}
.detail-category h3 {
height: 32px;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 0 10px;
color: #d0d0d0;
background: #303030;
border-top: 1px solid #393939;
border-bottom: 1px solid #171717;
font-size: 16px;
font-weight: 600;
}
.detail-row {
min-height: 40px;
display: grid;
grid-template-columns: minmax(180px, 0.45fr) minmax(220px, 0.55fr);
border-bottom: 1px solid #171717;
}
.detail-name,
.detail-value {
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 14px;
font-size: 16px;
}
.detail-name {
color: #c8c8c8;
border-right: 1px solid #151515;
}
.detail-value {
color: #e0e0e0;
background: #242424;
}
.details-empty {
min-height: 130px;
display: grid;
place-items: center;
padding: 20px;
color: #a8a8a8;
text-align: center;
font-size: 16px;
}
.spin {
animation: spin 0.85s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.shell {
padding: 0;
}
.panel {
min-height: 100vh;
height: 100vh;
border: 0;
grid-template-rows: 44px 52px 34px 1fr 44px;
}
.main-content {
grid-template-rows: minmax(170px, 1fr) minmax(260px, 0.95fr);
}
.tab {
min-width: auto;
flex: 1;
font-size: 16px;
padding: 0 8px;
}
.tab svg:last-child,
.tab:not(.active) span {
display: none;
}
.toolbar {
grid-template-columns: 32px 28px minmax(120px, 1fr) 32px 32px 32px;
gap: 4px;
padding: 6px;
}
.columns,
.outliner-row {
min-width: 720px;
}
.details-tab {
min-width: 160px;
font-size: 16px;
}
.detail-row {
grid-template-columns: minmax(130px, 0.42fr) minmax(170px, 0.58fr);
}
.footer {
font-size: 15px;
padding: 0 12px;
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

16
vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const unrealRemoteUrl = process.env.UNREAL_REMOTE_URL ?? "http://127.0.0.1:30010";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/remote": {
target: unrealRemoteUrl,
changeOrigin: true,
},
},
},
});