Files
La-Fabrik/src/components/editor/EditorControls.tsx
T
tom-boullay 0992aacec6
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (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
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
fix(editor): update transforms while dragging
2026-05-27 14:22:26 +02:00

468 lines
14 KiB
TypeScript

import {
Box,
Braces,
ChevronDown,
Download,
Expand,
Keyboard,
Lock,
MousePointer2,
Move3D,
Redo2,
RotateCw,
Save,
ScanSearch,
Undo2,
Unlock,
X,
} from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode } from "@/types/editor/editor";
interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
lockTerrainSelection: boolean;
onLockTerrainSelectionChange: (locked: boolean) => void;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
undoCount: number;
redoCount: number;
onUndo: () => void;
onRedo: () => void;
cameraActionLabel: string;
onCameraAction: () => void;
onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined;
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
isPlayerMode?: boolean;
}
const TRANSFORM_OPTIONS = [
{ mode: "translate", label: "Translate", shortcut: "T", Icon: Move3D },
{ mode: "rotate", label: "Rotate", shortcut: "R", Icon: RotateCw },
{ mode: "scale", label: "Scale", shortcut: "S", Icon: Expand },
] as const;
const EDITOR_SHORTCUTS = [
["Click", "Select object"],
["T / R / S", "Transform mode"],
["Ctrl Z / Y", "Undo / redo"],
["Esc", "Deselect"],
["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,
selectedNodeIndex,
mapNodes,
nodesCount,
selectedNodeName,
lockTerrainSelection,
onLockTerrainSelectionChange,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
undoCount,
redoCount,
onUndo,
onRedo,
cameraActionLabel,
onCameraAction,
onExportJson,
onSaveToServer,
onPlayerMode,
onPreviewCinematic,
isPlayerMode,
}: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
const transformValues = getTransformValues(selectedNode ?? null);
return (
<>
<aside className="editor-controls-panel" aria-label="Editor controls">
<header className="editor-panel-header">
<span className="editor-panel-kicker">Map Editor</span>
<h2>Scene controls</h2>
<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"
>
<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 className="editor-transform-label">
<span>{label}</span>
<small>{transformValues[mode]}</small>
</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
>
<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>
<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-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>
)}
<button className="editor-action-button" onClick={onCameraAction}>
<ScanSearch size={16} aria-hidden="true" />
{cameraActionLabel}
</button>
<label className="editor-checkbox-row">
<input
type="checkbox"
checked={lockTerrainSelection}
onChange={(event) =>
onLockTerrainSelectionChange(event.currentTarget.checked)
}
/>
<span>
<strong>Lock terrain</strong>
<small>Keep terrain visible but ignore terrain clicks</small>
</span>
</label>
</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>
</>
);
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
function formatVector(values: readonly [number, number, number]): string {
return `X ${formatNumber(values[0])} · Y ${formatNumber(values[1])} · Z ${formatNumber(values[2])}`;
}
function formatRotation(values: readonly [number, number, number]): string {
const degrees = values.map((value) => (value * 180) / Math.PI) as [
number,
number,
number,
];
return `X ${formatNumber(degrees[0])}° · Y ${formatNumber(degrees[1])}° · Z ${formatNumber(degrees[2])}°`;
}
function getTransformValues(
node: MapNode | null,
): Record<TransformMode, string> {
if (!node) {
return {
translate: "No selection",
rotate: "No selection",
scale: "No selection",
};
}
return {
translate: formatVector(node.position),
rotate: formatRotation(node.rotation),
scale: formatVector(node.scale),
};
}
interface JsonPreviewLine {
number: number;
content: string;
isSelected: boolean;
}
interface JsonPreview {
label: string;
lines: JsonPreviewLine[];
}
function getJsonPreview(
mapNodes: MapNode[],
selectedNodeIndex: number | null,
): JsonPreview {
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
if (selectedNodeIndex === null || !ranges[selectedNodeIndex]) {
return {
label: `${lines.length} raw lines`,
lines: lines.map((content, index) => ({
number: index + 1,
content,
isSelected: false,
})),
};
}
const range = ranges[selectedNodeIndex];
const selectedLines = lines.slice(range.start - 1, range.end);
return {
label: `Lines ${range.start}-${range.end}`,
lines: selectedLines.map((content, index) => ({
number: range.start + index,
content,
isSelected: true,
})),
};
}
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
lines: string[];
ranges: Array<{ start: number; end: number }>;
} {
const lines = ["["];
const ranges: Array<{ start: number; end: number }> = [];
mapNodes.forEach((node, index) => {
const objectLines = JSON.stringify(node, null, 2)
.split("\n")
.map((line) => ` ${line}`);
if (index < mapNodes.length - 1) {
objectLines[objectLines.length - 1] += ",";
}
const start = lines.length + 1;
lines.push(...objectLines);
ranges.push({ start, end: lines.length });
});
lines.push("]");
return { lines, ranges };
}