Enhance App component with validation and UI updates for better user experience

This commit is contained in:
2026-03-31 03:44:17 +02:00
parent 57a4d45692
commit f52cd5d884
3 changed files with 310 additions and 36 deletions

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from "react";
import type { EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import type { Vec3 } from "../core/vector";
import { DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes";
import type { SceneDocument, WorldSettings } from "../document/scene-document";
import { DEFAULT_GRID_SIZE } from "../geometry/grid-snapping";
import { ViewportHost } from "./viewport-host";
@@ -9,13 +13,24 @@ interface ViewportCanvasProps {
world: WorldSettings;
sceneDocument: SceneDocument;
selection: EditorSelection;
onBrushSelectionChange(selection: EditorSelection): void;
toolMode: ToolMode;
onSelectionChange(selection: EditorSelection): void;
onCreateBoxBrush(center: Vec3): void;
}
export function ViewportCanvas({ world, sceneDocument, selection, onBrushSelectionChange }: ViewportCanvasProps) {
function formatVec3(vector: Vec3 | null): string {
if (vector === null) {
return "Move over the grid to preview a snapped placement.";
}
return `${vector.x}, ${vector.y}, ${vector.z}`;
}
export function ViewportCanvas({ world, sceneDocument, selection, toolMode, onSelectionChange, onCreateBoxBrush }: ViewportCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<ViewportHost | null>(null);
const [viewportMessage, setViewportMessage] = useState<string | null>(null);
const [boxCreatePreview, setBoxCreatePreview] = useState<Vec3 | null>(null);
useEffect(() => {
const container = containerRef.current;
@@ -61,11 +76,46 @@ export function ViewportCanvas({ world, sceneDocument, selection, onBrushSelecti
}, [sceneDocument, selection]);
useEffect(() => {
hostRef.current?.setBrushSelectionChangeHandler(onBrushSelectionChange);
}, [onBrushSelectionChange]);
hostRef.current?.setBrushSelectionChangeHandler(onSelectionChange);
}, [onSelectionChange]);
useEffect(() => {
hostRef.current?.setCreateBoxBrushHandler(onCreateBoxBrush);
}, [onCreateBoxBrush]);
useEffect(() => {
hostRef.current?.setBoxCreatePreviewHandler(setBoxCreatePreview);
}, []);
useEffect(() => {
hostRef.current?.setToolMode(toolMode);
if (toolMode !== "box-create") {
setBoxCreatePreview(null);
}
}, [toolMode]);
return (
<div ref={containerRef} className="viewport-canvas" data-testid="viewport-shell" aria-label="Editor viewport">
<div
ref={containerRef}
className={`viewport-canvas viewport-canvas--${toolMode}`}
data-testid="viewport-shell"
aria-label="Editor viewport"
>
<div className="viewport-canvas__overlay" data-testid="viewport-overlay">
<div className="viewport-canvas__overlay-badge">{toolMode === "box-create" ? "Box Create" : "Select"}</div>
<div className="viewport-canvas__overlay-text">
{toolMode === "box-create"
? `Click to place a ${DEFAULT_BOX_BRUSH_SIZE.x} x ${DEFAULT_BOX_BRUSH_SIZE.y} x ${DEFAULT_BOX_BRUSH_SIZE.z} box on the ${DEFAULT_GRID_SIZE}m grid.`
: "Click a brush, face, or Player Start marker to update the authored selection."}
</div>
{toolMode !== "box-create" ? null : (
<div className="viewport-canvas__overlay-preview" data-testid="viewport-snap-preview">
Next box center: {formatVec3(boxCreatePreview)}
</div>
)}
</div>
{viewportMessage === null ? null : (
<div className="viewport-canvas__fallback" role="status">
<div className="viewport-canvas__fallback-title">Viewport Unavailable</div>

View File

@@ -404,6 +404,16 @@ export class ViewportHost {
}
private handlePointerDown = (event: PointerEvent) => {
if (this.toolMode === "box-create") {
const previewCenter = this.getBoxCreatePreviewCenter(event);
if (previewCenter !== null) {
this.createBoxBrushHandler?.(previewCenter);
}
return;
}
const bounds = this.renderer.domElement.getBoundingClientRect();
if (bounds.width === 0 || bounds.height === 0) {
@@ -464,6 +474,68 @@ export class ViewportHost {
});
};
private handlePointerMove = (event: PointerEvent) => {
if (this.toolMode !== "box-create") {
return;
}
this.setBoxCreatePreview(this.getBoxCreatePreviewCenter(event));
};
private handlePointerLeave = () => {
if (this.toolMode !== "box-create") {
return;
}
this.setBoxCreatePreview(null);
};
private getBoxCreatePreviewCenter(event: PointerEvent): Vec3 | null {
const bounds = this.renderer.domElement.getBoundingClientRect();
if (bounds.width === 0 || bounds.height === 0) {
return null;
}
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
this.raycaster.setFromCamera(this.pointer, this.camera);
if (this.raycaster.ray.intersectPlane(this.boxCreatePlane, this.boxCreateIntersection) === null) {
return null;
}
return {
x: snapValueToGrid(this.boxCreateIntersection.x, DEFAULT_GRID_SIZE),
y: DEFAULT_BOX_BRUSH_SIZE.y * 0.5,
z: snapValueToGrid(this.boxCreateIntersection.z, DEFAULT_GRID_SIZE)
};
}
private setBoxCreatePreview(center: Vec3 | null) {
if (
(center === null && this.lastBoxCreatePreviewCenter === null) ||
(center !== null &&
this.lastBoxCreatePreviewCenter !== null &&
center.x === this.lastBoxCreatePreviewCenter.x &&
center.y === this.lastBoxCreatePreviewCenter.y &&
center.z === this.lastBoxCreatePreviewCenter.z)
) {
return;
}
this.lastBoxCreatePreviewCenter = center === null ? null : { ...center };
this.boxCreatePreviewMesh.visible = center !== null;
this.boxCreatePreviewEdges.visible = center !== null;
if (center !== null) {
this.boxCreatePreviewMesh.position.set(center.x, center.y, center.z);
this.boxCreatePreviewEdges.position.set(center.x, center.y, center.z);
}
this.boxCreatePreviewHandler?.(this.lastBoxCreatePreviewCenter);
}
private render = () => {
this.animationFrame = window.requestAnimationFrame(this.render);
this.renderer.render(this.scene, this.camera);