update: organize editor controls panel
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-05-12 10:18:12 +02:00
parent 2bb2fff310
commit 15361db203
5 changed files with 363 additions and 162 deletions
+137 -59
View File
@@ -1,6 +1,7 @@
import { import {
Box, Box,
Braces, Braces,
ChevronDown,
Download, Download,
Expand, Expand,
Keyboard, Keyboard,
@@ -11,6 +12,8 @@ import {
RotateCw, RotateCw,
Save, Save,
Undo2, Undo2,
Unlock,
X,
} from "lucide-react"; } from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel"; import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel"; import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
@@ -25,6 +28,9 @@ interface EditorControlsProps {
mapNodes: MapNode[]; mapNodes: MapNode[];
nodesCount: number; nodesCount: number;
selectedNodeName: string | null; selectedNodeName: string | null;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
undoCount: number; undoCount: number;
redoCount: number; redoCount: number;
onUndo: () => void; onUndo: () => void;
@@ -50,6 +56,33 @@ const EDITOR_SHORTCUTS = [
["WASD", "Move when locked"], ["WASD", "Move when locked"],
] as const; ] 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({ export function EditorControls({
transformMode, transformMode,
onTransformModeChange, onTransformModeChange,
@@ -57,6 +90,9 @@ export function EditorControls({
mapNodes, mapNodes,
nodesCount, nodesCount,
selectedNodeName, selectedNodeName,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
undoCount, undoCount,
redoCount, redoCount,
onUndo, onUndo,
@@ -79,6 +115,28 @@ export function EditorControls({
<p>Select an object, choose a transform mode, then drag the gizmo.</p> <p>Select an object, choose a transform mode, then drag the gizmo.</p>
</header> </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 <section
className="editor-control-section" className="editor-control-section"
aria-labelledby="transform-heading" aria-labelledby="transform-heading"
@@ -127,25 +185,57 @@ export function EditorControls({
<section <section
className="editor-control-section" className="editor-control-section"
aria-labelledby="file-heading" aria-labelledby="selection-heading"
> >
<div className="editor-section-heading"> <div className="editor-section-heading">
<h3 id="file-heading">File</h3> <h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div> </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 <button
className="editor-action-button editor-action-button-primary" type="button"
onClick={onExportJson} onClick={onSelectionLockToggle}
aria-pressed={isSelectionLocked}
aria-label={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
title={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
> >
<Download size={16} aria-hidden="true" /> {isSelectionLocked ? (
Export JSON <Lock size={14} aria-hidden="true" />
) : (
<Unlock size={14} aria-hidden="true" />
)}
</button> </button>
<button
{onSaveToServer && ( type="button"
<button className="editor-action-button" onClick={onSaveToServer}> onClick={onClearSelection}
<Save size={16} aria-hidden="true" /> aria-label="Clear selection"
Save to server title="Clear selection"
>
<X size={14} aria-hidden="true" />
</button> </button>
</div>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</div>
)} )}
</section> </section>
@@ -170,54 +260,9 @@ export function EditorControls({
</section> </section>
<section <section
className="editor-control-section" className="editor-json-section"
aria-labelledby="selection-heading" 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"> <div className="editor-section-heading">
<h3 id="json-heading">JSON</h3> <h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span> <span>{jsonPreview.label}</span>
@@ -243,9 +288,42 @@ export function EditorControls({
</div> </div>
</section> </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 /> <EditorDialogueManifestPanel />
</EditorPanelGroup>
<EditorPanelGroup title="SRT" summary="Subtitles">
<EditorSrtPanel /> <EditorSrtPanel />
</EditorPanelGroup>
</aside> </aside>
</> </>
); );
+14 -2
View File
@@ -11,6 +11,7 @@ interface EditorMapProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
@@ -28,6 +29,7 @@ interface EditorNodeCommonProps {
isHovered: boolean; isHovered: boolean;
objectsMapRef: EditorNodeObjectRef; objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
} }
@@ -108,11 +110,13 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers( function createEditorNodePointerHandlers(
index: number, index: number,
onSelectNode: (index: number | null) => void, onSelectNode: (index: number | null) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void, onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers { ): EditorNodePointerHandlers {
return { return {
onClick: (event) => { onClick: (event) => {
event.stopPropagation(); event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(index); onSelectNode(index);
}, },
onPointerEnter: (event) => { onPointerEnter: (event) => {
@@ -130,6 +134,7 @@ export function EditorMap({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
onSelectNode, onSelectNode,
isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
@@ -192,8 +197,9 @@ export function EditorMap({
<axesHelper args={[10]} /> <axesHelper args={[10]} />
<group <group
onClick={(e: ThreeEvent<MouseEvent>) => { onClick={(event: ThreeEvent<MouseEvent>) => {
e.stopPropagation(); event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null); onSelectNode(null);
}} }}
> >
@@ -211,6 +217,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
); );
@@ -224,6 +231,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
); );
@@ -251,6 +259,7 @@ function EditorModelNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps & { }: EditorNodeCommonProps & {
modelUrl: string; modelUrl: string;
@@ -269,6 +278,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(groupRef, index, node, objectsMapRef); useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
@@ -343,12 +353,14 @@ function EditorFallbackNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps) { }: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null); const meshRef = useRef<THREE.Mesh>(null);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(meshRef, index, node, objectsMapRef); useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
+12 -2
View File
@@ -17,6 +17,7 @@ interface EditorSceneProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
@@ -35,6 +36,7 @@ export function EditorScene({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
onSelectNode, onSelectNode,
isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
@@ -68,7 +70,7 @@ export function EditorScene({
if (selectedNodeIndex !== null) { if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case "escape": case "escape":
onSelectNode(null); if (!isSelectionLocked) onSelectNode(null);
break; break;
case "t": case "t":
onTransformModeChange("translate"); onTransformModeChange("translate");
@@ -85,7 +87,14 @@ export function EditorScene({
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]); }, [
isSelectionLocked,
selectedNodeIndex,
onSelectNode,
onTransformModeChange,
onUndo,
onRedo,
]);
return ( return (
<> <>
@@ -113,6 +122,7 @@ export function EditorScene({
sceneData={sceneData} sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
transformMode={transformMode} transformMode={transformMode}
+89 -1
View File
@@ -1081,6 +1081,61 @@ canvas {
line-height: 1.45; 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 { .editor-control-section {
padding: 14px 12px; padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09); border-top: 1px solid rgba(255, 255, 255, 0.09);
@@ -1252,7 +1307,8 @@ canvas {
} }
.editor-selected-info { .editor-selected-info {
display: flex; display: grid;
grid-template-columns: 17px 1fr auto;
align-items: center; align-items: center;
gap: 11px; gap: 11px;
background: #ffffff; background: #ffffff;
@@ -1262,6 +1318,38 @@ canvas {
color: #050505; 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 strong,
.editor-selected-info span { .editor-selected-info span {
display: block; display: block;
+13
View File
@@ -31,6 +31,7 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] = const [transformMode, setTransformMode] =
useState<TransformMode>("translate"); useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false); const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [cinematicPreviewRequest, setCinematicPreviewRequest] = const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null); useState<EditorCinematicPreviewRequest | null>(null);
@@ -47,6 +48,14 @@ export function EditorPage(): React.JSX.Element {
setSelectedNodeIndex(index); setSelectedNodeIndex(index);
}, []); }, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
}, []);
const handleSelectionLockToggle = useCallback(() => {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleHoverNode = useCallback((index: number | null) => { const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index); setHoveredNodeIndex(index);
}, []); }, []);
@@ -180,6 +189,7 @@ export function EditorPage(): React.JSX.Element {
sceneData={sceneData!} sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode} onSelectNode={handleSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode} onHoverNode={handleHoverNode}
transformMode={transformMode} transformMode={transformMode}
@@ -207,6 +217,9 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null ? sceneData.mapNodes[selectedNodeIndex].name || null
: null : null
} }
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
undoCount={undoCount} undoCount={undoCount}
redoCount={redoCount} redoCount={redoCount}
onUndo={handleUndo} onUndo={handleUndo}