Initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
80
README.md
Normal file
80
README.md
Normal 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
12
index.html
Normal 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
1869
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
4
public/favicon.svg
Normal 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
1135
src/main.tsx
Normal file
File diff suppressed because it is too large
Load Diff
521
src/styles.css
Normal file
521
src/styles.css
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
16
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user