auto-git:

[add] .prettierrc.json
 [add] eslint.config.js
 [add] index.html
 [add] package.json
 [add] playwright.config.ts
 [add] src/app/App.tsx
 [add] src/app/app.css
 [add] src/app/editor-store.ts
 [add] src/app/use-editor-store.ts
 [add] src/assets/.gitkeep
 [add] src/commands/command-history.ts
 [add] src/commands/command.ts
 [add] src/commands/set-scene-name-command.ts
 [add] src/core/ids.ts
 [add] src/core/selection.ts
 [add] src/core/tool-mode.ts
 [add] src/core/vector.ts
 [add] src/document/migrate-scene-document.ts
 [add] src/document/scene-document.ts
 [add] src/entities/.gitkeep
 [add] src/geometry/.gitkeep
 [add] src/main.tsx
 [add] src/materials/.gitkeep
 [add] src/runtime-three/.gitkeep
 [add] src/serialization/local-draft-storage.ts
 [add] src/serialization/scene-document-json.ts
 [add] src/shared-ui/Panel.tsx
 [add] src/viewport-three/ViewportCanvas.tsx
 [add] src/viewport-three/viewport-host.ts
 [add] src/vite-env.d.ts
 [add] tests/domain/create-empty-scene-document.test.ts
 [add] tests/domain/editor-store.test.ts
 [add] tests/e2e/app-smoke.e2e.ts
 [add] tests/serialization/scene-document-json.test.ts
 [add] tests/setup/vitest.setup.ts
 [add] tsconfig.json
 [add] vite.config.ts
 [add] vitest.config.ts
This commit is contained in:
2026-03-31 01:29:35 +02:00
parent c90282ea45
commit 3af579c6bb
38 changed files with 1662 additions and 0 deletions

260
src/app/App.tsx Normal file
View File

@@ -0,0 +1,260 @@
import { useEffect, useRef, useState } from "react";
import { createSetSceneNameCommand } from "../commands/set-scene-name-command";
import { Panel } from "../shared-ui/Panel";
import { ViewportCanvas } from "../viewport-three/ViewportCanvas";
import type { EditorStore } from "./editor-store";
import { useEditorStoreState } from "./use-editor-store";
interface AppProps {
store: EditorStore;
}
function describeSelection(selectionKind: string): string {
switch (selectionKind) {
case "none":
return "No authored selection yet";
case "brushes":
return "Brush selection placeholder";
case "entities":
return "Entity selection placeholder";
case "modelInstances":
return "Model instance selection placeholder";
default:
return "Unknown selection";
}
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "An unexpected error occurred.";
}
export function App({ store }: AppProps) {
const editorState = useEditorStoreState(store);
const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name);
const [statusMessage, setStatusMessage] = useState("Viewport shell ready.");
const importInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setSceneNameDraft(editorState.document.name);
}, [editorState.document.name]);
const applySceneName = () => {
const normalizedName = sceneNameDraft.trim() || "Untitled Scene";
if (normalizedName === editorState.document.name) {
setStatusMessage("Scene name is already current.");
return;
}
store.executeCommand(createSetSceneNameCommand(normalizedName));
setStatusMessage(`Scene renamed to ${normalizedName}.`);
};
const handleSaveDraft = () => {
const didSave = store.saveDraft();
setStatusMessage(didSave ? "Local draft saved." : "Local draft storage is unavailable.");
};
const handleLoadDraft = () => {
try {
const didLoad = store.loadDraft();
setStatusMessage(didLoad ? "Local draft loaded." : "No local draft was found.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const handleExportJson = () => {
const exportedJson = store.exportDocumentJson();
const blob = new Blob([exportedJson], { type: "application/json" });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${editorState.document.name.replace(/\s+/g, "-").toLowerCase() || "scene"}.json`;
anchor.click();
URL.revokeObjectURL(objectUrl);
setStatusMessage("Scene document exported as JSON.");
};
const handleImportButtonClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
if (file === undefined) {
return;
}
try {
const source = await file.text();
store.importDocumentJson(source);
setStatusMessage(`Imported ${file.name}.`);
} catch (error) {
setStatusMessage(getErrorMessage(error));
} finally {
event.currentTarget.value = "";
}
};
return (
<div className="app-shell">
<header className="toolbar">
<div className="toolbar__brand">
<div className="toolbar__title">WebEditor3D</div>
<div className="toolbar__subtitle">Milestone 0 foundation slice</div>
</div>
<div className="toolbar__actions">
<div className="toolbar__group">
<button
className={`toolbar__button ${editorState.toolMode === "select" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("select")}
>
Select
</button>
<button
className={`toolbar__button ${editorState.toolMode === "box-create" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("box-create")}
>
Box
</button>
<button
className={`toolbar__button ${editorState.toolMode === "play" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("play")}
>
Play
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button" type="button" onClick={handleSaveDraft}>
Save Draft
</button>
<button className="toolbar__button" type="button" onClick={handleLoadDraft}>
Load Draft
</button>
<button className="toolbar__button" type="button" onClick={handleExportJson}>
Export JSON
</button>
<button className="toolbar__button" type="button" onClick={handleImportButtonClick}>
Import JSON
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button" type="button" disabled={!editorState.canUndo} onClick={() => store.undo()}>
Undo
</button>
<button className="toolbar__button" type="button" disabled={!editorState.canRedo} onClick={() => store.redo()}>
Redo
</button>
</div>
</div>
</header>
<div className="workspace">
<aside className="side-column">
<Panel title="Document">
<div className="stat-grid">
<div className="stat-card">
<div className="label">Version</div>
<div className="value">v{editorState.document.version}</div>
</div>
<div className="stat-card">
<div className="label">Tool Mode</div>
<div className="value">{editorState.toolMode}</div>
</div>
</div>
<label className="form-field">
<span className="label">Scene Name</span>
<input
className="text-input"
type="text"
value={sceneNameDraft}
onChange={(event) => setSceneNameDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
applySceneName();
}
}}
/>
</label>
<div className="inline-actions">
<button className="toolbar__button toolbar__button--accent" type="button" onClick={applySceneName}>
Apply Command
</button>
</div>
</Panel>
<Panel title="Outliner">
<ul className="placeholder-list">
<li>Brushes: {Object.keys(editorState.document.brushes).length}</li>
<li>Entities: {Object.keys(editorState.document.entities).length}</li>
<li>Model Instances: {Object.keys(editorState.document.modelInstances).length}</li>
</ul>
</Panel>
</aside>
<main className="viewport-region">
<div className="viewport-region__header">
<div className="viewport-region__title">Viewport</div>
<div className="viewport-region__caption">Imperative three.js editor surface</div>
</div>
<ViewportCanvas world={editorState.document.world} />
</main>
<aside className="side-column">
<Panel title="Inspector">
<div className="stat-card">
<div className="label">Selection</div>
<div className="value">{describeSelection(editorState.selection.kind)}</div>
</div>
<ul className="placeholder-list">
<li>Document is the canonical source of truth.</li>
<li>Viewport state is derived and disposable.</li>
<li>Real geometry tools intentionally start in Milestone 1.</li>
</ul>
</Panel>
<Panel title="Runner">
<ul className="placeholder-list">
<li>Built-in runner shell reserved for later slices.</li>
<li>Current focus is versioned documents, persistence, and editor boot flow.</li>
</ul>
</Panel>
</aside>
</div>
<footer className="status-bar">
<div>
<span className="status-bar__strong">Status:</span> {statusMessage}
</div>
<div>
<span className="status-bar__strong">History:</span> {editorState.lastCommandLabel ?? "No commands yet"}
</div>
</footer>
<input
ref={importInputRef}
className="visually-hidden"
type="file"
accept=".json,application/json"
onChange={handleImportChange}
/>
</div>
);
}