Feat/env-manager2 #4
@@ -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,6 +115,28 @@ export function EditorControls({
|
||||
<p>Select an object, choose a transform mode, then drag the gizmo.</p>
|
||||
</header>
|
||||
|
||||
<EditorPanelGroup title="Editor" summary="Map tools" defaultOpen>
|
||||
<EditorPanelGroup title="Shortcuts" summary="Keys">
|
||||
<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>
|
||||
</EditorPanelGroup>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="transform-heading"
|
||||
@@ -127,25 +185,57 @@ export function EditorControls({
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="file-heading"
|
||||
aria-labelledby="selection-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="file-heading">File</h3>
|
||||
<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-selected-actions">
|
||||
<button
|
||||
className="editor-action-button editor-action-button-primary"
|
||||
onClick={onExportJson}
|
||||
type="button"
|
||||
onClick={onSelectionLockToggle}
|
||||
aria-pressed={isSelectionLocked}
|
||||
aria-label={
|
||||
isSelectionLocked ? "Unlock selection" : "Lock selection"
|
||||
}
|
||||
title={
|
||||
isSelectionLocked ? "Unlock selection" : "Lock selection"
|
||||
}
|
||||
>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
Export JSON
|
||||
{isSelectionLocked ? (
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<Unlock size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{onSaveToServer && (
|
||||
<button className="editor-action-button" onClick={onSaveToServer}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save to server
|
||||
<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>
|
||||
|
||||
@@ -170,54 +260,9 @@ export function EditorControls({
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="selection-heading"
|
||||
className="editor-json-section"
|
||||
aria-labelledby="json-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>
|
||||
) : (
|
||||
<div className="editor-no-selection">
|
||||
<MousePointer2 size={17} aria-hidden="true" />
|
||||
No object selected
|
||||
</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>
|
||||
@@ -243,9 +288,42 @@ export function EditorControls({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user