Feat/env-manager2 #4

Merged
math-pixel merged 2 commits from feat/env-manager into develop 2026-05-12 08:59:28 +00:00
5 changed files with 363 additions and 162 deletions
Showing only changes of commit 15361db203 - Show all commits
+235 -157
View File
@@ -1,6 +1,7 @@
import {
Box,
Braces,
ChevronDown,
Download,
Expand,
Keyboard,
@@ -11,6 +12,8 @@ import {
RotateCw,
Save,
Undo2,
Unlock,
X,
} from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
@@ -25,6 +28,9 @@ interface EditorControlsProps {
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
undoCount: number;
redoCount: number;
onUndo: () => void;
@@ -50,6 +56,33 @@ const EDITOR_SHORTCUTS = [
["WASD", "Move when locked"],
] as const;
interface EditorPanelGroupProps {
title: string;
summary?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
function EditorPanelGroup({
title,
summary,
defaultOpen = false,
children,
}: EditorPanelGroupProps): React.JSX.Element {
return (
<details className="editor-panel-group" open={defaultOpen}>
<summary className="editor-panel-group-summary">
<span>{title}</span>
<span className="editor-panel-group-meta">
{summary ? <span>{summary}</span> : null}
<ChevronDown size={15} aria-hidden="true" />
</span>
</summary>
<div className="editor-panel-group-content">{children}</div>
</details>
);
}
export function EditorControls({
transformMode,
onTransformModeChange,
@@ -57,6 +90,9 @@ export function EditorControls({
mapNodes,
nodesCount,
selectedNodeName,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
undoCount,
redoCount,
onUndo,
@@ -79,173 +115,215 @@ export function EditorControls({
<p>Select an object, choose a transform mode, then drag the gizmo.</p>
</header>
<section
className="editor-control-section"
aria-labelledby="transform-heading"
>
<div className="editor-section-heading">
<h3 id="transform-heading">Transform</h3>
<span>T / R / S</span>
</div>
<div className="editor-transform-buttons">
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
<button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
<EditorPanelGroup title="Editor" summary="Map tools" defaultOpen>
<EditorPanelGroup title="Shortcuts" summary="Keys">
<section
className="editor-control-section"
aria-labelledby="shortcuts-heading"
>
<Undo2 size={15} aria-hidden="true" />
Undo
<span>{undoCount}</span>
</button>
<button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<div className="editor-section-heading">
<h3 id="shortcuts-heading">Shortcuts</h3>
<Keyboard size={15} aria-hidden="true" />
</div>
<section
className="editor-control-section"
aria-labelledby="file-heading"
>
<div className="editor-section-heading">
<h3 id="file-heading">File</h3>
</div>
<dl className="editor-shortcuts-list">
{EDITOR_SHORTCUTS.map(([keys, description]) => (
<div key={keys}>
<dt>{keys}</dt>
<dd>{description}</dd>
</div>
))}
</dl>
</section>
</EditorPanelGroup>
<button
className="editor-action-button editor-action-button-primary"
onClick={onExportJson}
<section
className="editor-control-section"
aria-labelledby="transform-heading"
>
<Download size={16} aria-hidden="true" />
Export JSON
</button>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="selection-heading"
>
<div className="editor-section-heading">
<h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div>
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
<div className="editor-section-heading">
<h3 id="transform-heading">Transform</h3>
<span>T / R / S</span>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
<div className="editor-transform-buttons">
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
<button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="shortcuts-heading"
>
<div className="editor-section-heading">
<h3 id="shortcuts-heading">Shortcuts</h3>
<Keyboard size={15} aria-hidden="true" />
</div>
<dl className="editor-shortcuts-list">
{EDITOR_SHORTCUTS.map(([keys, description]) => (
<div key={keys}>
<dt>{keys}</dt>
<dd>{description}</dd>
</div>
))}
</dl>
</section>
<section className="editor-json-section" aria-labelledby="json-heading">
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
>
<span>{line.number}</span>
{line.content || " "}
</code>
))}
</pre>
<Undo2 size={15} aria-hidden="true" />
Undo
<span>{undoCount}</span>
</button>
<button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<div className="editor-json-hint">
<Braces size={14} aria-hidden="true" />
{selectedNodeIndex === null
? "Raw map JSON"
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="selection-heading"
>
<div className="editor-section-heading">
<h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div>
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
<EditorDialogueManifestPanel />
<EditorSrtPanel />
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
<div className="editor-selected-actions">
<button
type="button"
onClick={onSelectionLockToggle}
aria-pressed={isSelectionLocked}
aria-label={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
title={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
>
{isSelectionLocked ? (
<Lock size={14} aria-hidden="true" />
) : (
<Unlock size={14} aria-hidden="true" />
)}
</button>
<button
type="button"
onClick={onClearSelection}
aria-label="Clear selection"
title="Clear selection"
>
<X size={14} aria-hidden="true" />
</button>
</div>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</div>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-json-section"
aria-labelledby="json-heading"
>
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
>
<span>{line.number}</span>
{line.content || " "}
</code>
))}
</pre>
<div className="editor-json-hint">
<Braces size={14} aria-hidden="true" />
{selectedNodeIndex === null
? "Raw map JSON"
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="file-heading"
>
<div className="editor-section-heading">
<h3 id="file-heading">File</h3>
</div>
<button
className="editor-action-button editor-action-button-primary"
onClick={onExportJson}
>
<Download size={16} aria-hidden="true" />
Export JSON
</button>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
</EditorPanelGroup>
<EditorPanelGroup title="Cinematics" summary="Timeline">
<EditorCinematicManifestPanel
onPreviewCinematic={onPreviewCinematic}
/>
</EditorPanelGroup>
<EditorPanelGroup title="Dialogues" summary="Manifest">
<EditorDialogueManifestPanel />
</EditorPanelGroup>
<EditorPanelGroup title="SRT" summary="Subtitles">
<EditorSrtPanel />
</EditorPanelGroup>
</aside>
</>
);
+14 -2
View File
@@ -11,6 +11,7 @@ interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
@@ -28,6 +29,7 @@ interface EditorNodeCommonProps {
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void;
}
@@ -108,11 +110,13 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
return {
onClick: (event) => {
event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(index);
},
onPointerEnter: (event) => {
@@ -130,6 +134,7 @@ export function EditorMap({
sceneData,
selectedNodeIndex,
onSelectNode,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
@@ -192,8 +197,9 @@ export function EditorMap({
<axesHelper args={[10]} />
<group
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onClick={(event: ThreeEvent<MouseEvent>) => {
event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null);
}}
>
@@ -211,6 +217,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
);
@@ -224,6 +231,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
);
@@ -251,6 +259,7 @@ function EditorModelNode({
isHovered,
objectsMapRef,
onSelectNode,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & {
modelUrl: string;
@@ -269,6 +278,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
@@ -343,12 +353,14 @@ function EditorFallbackNode({
isHovered,
objectsMapRef,
onSelectNode,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
+12 -2
View File
@@ -17,6 +17,7 @@ interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
@@ -35,6 +36,7 @@ export function EditorScene({
sceneData,
selectedNodeIndex,
onSelectNode,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
@@ -68,7 +70,7 @@ export function EditorScene({
if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) {
case "escape":
onSelectNode(null);
if (!isSelectionLocked) onSelectNode(null);
break;
case "t":
onTransformModeChange("translate");
@@ -85,7 +87,14 @@ export function EditorScene({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]);
}, [
isSelectionLocked,
selectedNodeIndex,
onSelectNode,
onTransformModeChange,
onUndo,
onRedo,
]);
return (
<>
@@ -113,6 +122,7 @@ export function EditorScene({
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
+89 -1
View File
@@ -1081,6 +1081,61 @@ canvas {
line-height: 1.45;
}
.editor-panel-group {
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-panel-group-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 12px;
color: #ffffff;
cursor: pointer;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
list-style: none;
text-transform: uppercase;
user-select: none;
}
.editor-panel-group-summary::-webkit-details-marker {
display: none;
}
.editor-panel-group-summary:hover {
color: #f2f2f2;
}
.editor-panel-group-meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: #777777;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0;
text-transform: none;
}
.editor-panel-group-meta svg {
transition: transform 160ms ease;
}
.editor-panel-group[open] .editor-panel-group-meta svg {
transform: rotate(180deg);
}
.editor-panel-group-content > .editor-control-section:first-child,
.editor-panel-group-content > .editor-json-section:first-child,
.editor-panel-group-content > .editor-cinematic-manifest-section:first-child,
.editor-panel-group-content > .editor-dialogue-manifest-section:first-child,
.editor-panel-group-content > .editor-srt-section:first-child {
border-top: 0;
}
.editor-control-section {
padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
@@ -1252,7 +1307,8 @@ canvas {
}
.editor-selected-info {
display: flex;
display: grid;
grid-template-columns: 17px 1fr auto;
align-items: center;
gap: 11px;
background: #ffffff;
@@ -1262,6 +1318,38 @@ canvas {
color: #050505;
}
.editor-selected-actions {
display: inline-flex;
gap: 6px;
}
.editor-selected-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 27px;
height: 27px;
padding: 0;
color: #050505;
background: rgba(0, 0, 0, 0.06);
border: 0;
border-radius: 9px;
cursor: pointer;
transition:
background 160ms ease,
transform 160ms ease;
}
.editor-selected-actions button:hover {
background: rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
}
.editor-selected-actions button[aria-pressed="true"] {
color: #ffffff;
background: #050505;
}
.editor-selected-info strong,
.editor-selected-info span {
display: block;
+13
View File
@@ -31,6 +31,7 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null);
@@ -47,6 +48,14 @@ export function EditorPage(): React.JSX.Element {
setSelectedNodeIndex(index);
}, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
}, []);
const handleSelectionLockToggle = useCallback(() => {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
@@ -180,6 +189,7 @@ export function EditorPage(): React.JSX.Element {
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
@@ -207,6 +217,9 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}