From 3af579c6bb7468df04d80312ebf9eae689d65a37 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 01:29:35 +0200 Subject: [PATCH] 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 --- .prettierrc.json | 5 + eslint.config.js | 32 ++ index.html | 12 + package.json | 43 +++ playwright.config.ts | 26 ++ src/app/App.tsx | 260 +++++++++++++ src/app/app.css | 362 ++++++++++++++++++ src/app/editor-store.ts | 178 +++++++++ src/app/use-editor-store.ts | 7 + src/assets/.gitkeep | 1 + src/commands/command-history.ts | 49 +++ src/commands/command.ts | 19 + src/commands/set-scene-name-command.ts | 36 ++ src/core/ids.ts | 10 + src/core/selection.ts | 5 + src/core/tool-mode.ts | 1 + src/core/vector.ts | 11 + src/document/migrate-scene-document.ts | 114 ++++++ src/document/scene-document.ts | 71 ++++ src/entities/.gitkeep | 1 + src/geometry/.gitkeep | 1 + src/main.tsx | 25 ++ src/materials/.gitkeep | 1 + src/runtime-three/.gitkeep | 1 + src/serialization/local-draft-storage.ts | 45 +++ src/serialization/scene-document-json.ts | 19 + src/shared-ui/Panel.tsx | 14 + src/viewport-three/ViewportCanvas.tsx | 37 ++ src/viewport-three/viewport-host.ts | 100 +++++ src/vite-env.d.ts | 1 + .../create-empty-scene-document.test.ts | 15 + tests/domain/editor-store.test.ts | 56 +++ tests/e2e/app-smoke.e2e.ts | 24 ++ .../serialization/scene-document-json.test.ts | 31 ++ tests/setup/vitest.setup.ts | 1 + tsconfig.json | 23 ++ vite.config.ts | 10 + vitest.config.ts | 15 + 38 files changed, 1662 insertions(+) create mode 100644 .prettierrc.json create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 src/app/App.tsx create mode 100644 src/app/app.css create mode 100644 src/app/editor-store.ts create mode 100644 src/app/use-editor-store.ts create mode 100644 src/assets/.gitkeep create mode 100644 src/commands/command-history.ts create mode 100644 src/commands/command.ts create mode 100644 src/commands/set-scene-name-command.ts create mode 100644 src/core/ids.ts create mode 100644 src/core/selection.ts create mode 100644 src/core/tool-mode.ts create mode 100644 src/core/vector.ts create mode 100644 src/document/migrate-scene-document.ts create mode 100644 src/document/scene-document.ts create mode 100644 src/entities/.gitkeep create mode 100644 src/geometry/.gitkeep create mode 100644 src/main.tsx create mode 100644 src/materials/.gitkeep create mode 100644 src/runtime-three/.gitkeep create mode 100644 src/serialization/local-draft-storage.ts create mode 100644 src/serialization/scene-document-json.ts create mode 100644 src/shared-ui/Panel.tsx create mode 100644 src/viewport-three/ViewportCanvas.tsx create mode 100644 src/viewport-three/viewport-host.ts create mode 100644 src/vite-env.d.ts create mode 100644 tests/domain/create-empty-scene-document.test.ts create mode 100644 tests/domain/editor-store.test.ts create mode 100644 tests/e2e/app-smoke.e2e.ts create mode 100644 tests/serialization/scene-document-json.test.ts create mode 100644 tests/setup/vitest.setup.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..5d3c35d9 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": false, + "semi": true, + "trailingComma": "none" +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..9b206480 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["coverage", "dist", "playwright-report", "test-results"] + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node + } + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] + } + } +); diff --git a/index.html b/index.html new file mode 100644 index 00000000..aebe37d0 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + WebEditor3D + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 00000000..15a14d64 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "webeditor3d", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "format": "prettier --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@playwright/test": "^1.48.2", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@types/node": "^22.8.7", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.13", + "globals": "^15.11.0", + "jsdom": "^25.0.1", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "typescript-eslint": "^8.10.0", + "vite": "^5.4.10", + "vitest": "^2.1.4" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..443159ab --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + expect: { + timeout: 5_000 + }, + use: { + baseURL: "http://127.0.0.1:4173", + trace: "on-first-retry" + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"] + } + } + ], + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI + } +}); diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 00000000..9445068f --- /dev/null +++ b/src/app/App.tsx @@ -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(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) => { + 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 ( +
+
+
+
WebEditor3D
+
Milestone 0 foundation slice
+
+ +
+
+ + + +
+ +
+ + + + +
+ +
+ + +
+
+
+ +
+ + +
+
+
Viewport
+
Imperative three.js editor surface
+
+ +
+ + +
+ +
+
+ Status: {statusMessage} +
+
+ History: {editorState.lastCommandLabel ?? "No commands yet"} +
+
+ + +
+ ); +} diff --git a/src/app/app.css b/src/app/app.css new file mode 100644 index 00000000..7425867e --- /dev/null +++ b/src/app/app.css @@ -0,0 +1,362 @@ +:root { + color-scheme: dark; + font-family: "Avenir Next", "Segoe UI", sans-serif; + line-height: 1.5; + font-weight: 400; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + --color-bg: #13171d; + --color-surface: rgba(31, 36, 45, 0.92); + --color-surface-strong: #2a313d; + --color-border: rgba(255, 255, 255, 0.08); + --color-text: #f2eee8; + --color-muted: #a8b0bd; + --color-accent: #cf7b42; + --color-accent-strong: #f3be8f; + --color-accent-soft: rgba(207, 123, 66, 0.18); + --shadow-panel: 0 18px 48px rgba(3, 7, 12, 0.38); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(80, 96, 120, 0.35) 0%, rgba(23, 28, 37, 0) 42%), + linear-gradient(180deg, #1b2029 0%, #101318 100%); + color: var(--color-text); +} + +button, +input { + font: inherit; +} + +button { + border: 1px solid transparent; + border-radius: 12px; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease, + transform 120ms ease; +} + +button:hover:not(:disabled) { + transform: translateY(-1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.app-shell { + display: grid; + grid-template-rows: 64px minmax(0, 1fr) 36px; + min-height: 100vh; + gap: 12px; + padding: 12px; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 18px; + background: rgba(18, 22, 28, 0.88); + border: 1px solid var(--color-border); + border-radius: 18px; + box-shadow: var(--shadow-panel); + backdrop-filter: blur(16px); +} + +.toolbar__brand { + display: flex; + flex-direction: column; +} + +.toolbar__title { + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.toolbar__subtitle { + color: var(--color-muted); + font-size: 0.78rem; +} + +.toolbar__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.toolbar__group { + display: flex; + gap: 8px; + padding: 8px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--color-border); + border-radius: 14px; +} + +.toolbar__button { + padding: 0.65rem 0.9rem; + background: var(--color-surface-strong); + color: var(--color-text); +} + +.toolbar__button:hover:not(:disabled) { + border-color: rgba(255, 255, 255, 0.12); +} + +.toolbar__button--active { + background: var(--color-accent-soft); + border-color: rgba(207, 123, 66, 0.55); + color: var(--color-accent-strong); +} + +.toolbar__button--accent { + background: var(--color-accent); + color: #20140c; + font-weight: 700; +} + +.workspace { + display: grid; + grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px); + gap: 12px; + min-height: 0; +} + +.side-column { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.panel { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 18px; + overflow: hidden; + box-shadow: var(--shadow-panel); +} + +.panel__header { + padding: 14px 16px; + border-bottom: 1px solid var(--color-border); + color: #e4d8ca; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.panel__body { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.stat-card { + padding: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%); + border: 1px solid var(--color-border); + border-radius: 14px; +} + +.label { + color: var(--color-muted); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.value { + margin-top: 0.35rem; + font-size: 0.95rem; + font-weight: 600; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.text-input { + width: 100%; + padding: 0.85rem 0.95rem; + background: rgba(7, 9, 13, 0.4); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 12px; +} + +.inline-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.placeholder-list { + display: flex; + flex-direction: column; + gap: 10px; + list-style: none; + margin: 0; + padding: 0; +} + +.placeholder-list li { + padding: 10px 12px; + color: var(--color-muted); + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.11); + border-radius: 12px; +} + +.viewport-region { + display: grid; + grid-template-rows: 42px minmax(0, 1fr); + min-height: 0; + background: rgba(20, 24, 31, 0.92); + border: 1px solid var(--color-border); + border-radius: 24px; + overflow: hidden; + box-shadow: var(--shadow-panel); +} + +.viewport-region__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 100%); + border-bottom: 1px solid var(--color-border); +} + +.viewport-region__title { + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.viewport-region__caption { + color: var(--color-muted); + font-size: 0.82rem; +} + +.viewport-canvas { + position: relative; + min-height: 420px; + background: + radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%), + linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%); +} + +.viewport-canvas canvas { + display: block; + width: 100%; + height: 100%; +} + +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 16px; + background: rgba(18, 22, 28, 0.88); + border: 1px solid var(--color-border); + border-radius: 16px; + color: var(--color-muted); +} + +.status-bar__strong { + color: var(--color-text); + font-weight: 700; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 1100px) { + .workspace { + grid-template-columns: minmax(240px, 280px) minmax(0, 1fr); + } + + .workspace > .side-column:last-child { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { + .app-shell { + grid-template-rows: auto auto auto; + } + + .toolbar { + flex-direction: column; + align-items: flex-start; + padding: 16px; + } + + .workspace { + grid-template-columns: 1fr; + } + + .workspace > .side-column:last-child { + display: flex; + } + + .viewport-canvas { + min-height: 320px; + } + + .status-bar { + flex-direction: column; + align-items: flex-start; + padding: 12px 16px; + } +} diff --git a/src/app/editor-store.ts b/src/app/editor-store.ts new file mode 100644 index 00000000..86135c7b --- /dev/null +++ b/src/app/editor-store.ts @@ -0,0 +1,178 @@ +import { CommandHistory } from "../commands/command-history"; +import type { CommandContext, EditorCommand } from "../commands/command"; +import type { EditorSelection } from "../core/selection"; +import type { ToolMode } from "../core/tool-mode"; +import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document"; +import { + DEFAULT_SCENE_DRAFT_STORAGE_KEY, + loadSceneDocumentDraft, + type KeyValueStorage, + saveSceneDocumentDraft +} from "../serialization/local-draft-storage"; +import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json"; + +export interface EditorStoreState { + document: SceneDocument; + selection: EditorSelection; + toolMode: ToolMode; + canUndo: boolean; + canRedo: boolean; + lastCommandLabel: string | null; +} + +interface EditorStoreOptions { + initialDocument?: SceneDocument; + storage?: KeyValueStorage | null; + storageKey?: string; +} + +type EditorStoreListener = () => void; + +export class EditorStore { + private document: SceneDocument; + private selection: EditorSelection = { kind: "none" }; + private toolMode: ToolMode = "select"; + private readonly history = new CommandHistory(); + private readonly listeners = new Set(); + private readonly storage: KeyValueStorage | null; + private readonly storageKey: string; + private lastCommandLabel: string | null = null; + + private readonly commandContext: CommandContext = { + getDocument: () => this.document, + setDocument: (document) => { + this.document = document; + }, + getSelection: () => this.selection, + setSelection: (selection) => { + this.selection = selection; + }, + getToolMode: () => this.toolMode, + setToolMode: (toolMode) => { + this.toolMode = toolMode; + } + }; + + constructor(options: EditorStoreOptions = {}) { + this.document = options.initialDocument ?? createEmptySceneDocument(); + this.storage = options.storage ?? null; + this.storageKey = options.storageKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY; + } + + subscribe = (listener: EditorStoreListener) => { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + }; + + getState = (): EditorStoreState => ({ + document: this.document, + selection: this.selection, + toolMode: this.toolMode, + canUndo: this.history.canUndo(), + canRedo: this.history.canRedo(), + lastCommandLabel: this.lastCommandLabel + }); + + setToolMode(toolMode: ToolMode) { + if (this.toolMode === toolMode) { + return; + } + + this.toolMode = toolMode; + this.emit(); + } + + setSelection(selection: EditorSelection) { + this.selection = selection; + this.emit(); + } + + executeCommand(command: EditorCommand) { + this.history.execute(command, this.commandContext); + this.lastCommandLabel = command.label; + this.emit(); + } + + undo(): boolean { + const command = this.history.undo(this.commandContext); + + if (command === null) { + return false; + } + + this.lastCommandLabel = `Undid ${command.label}`; + this.emit(); + return true; + } + + redo(): boolean { + const command = this.history.redo(this.commandContext); + + if (command === null) { + return false; + } + + this.lastCommandLabel = `Redid ${command.label}`; + this.emit(); + return true; + } + + replaceDocument(document: SceneDocument, resetHistory = true) { + this.document = document; + this.selection = { kind: "none" }; + + if (resetHistory) { + this.history.clear(); + this.lastCommandLabel = null; + } + + this.emit(); + } + + saveDraft(): boolean { + if (this.storage === null) { + return false; + } + + saveSceneDocumentDraft(this.storage, this.document, this.storageKey); + return true; + } + + loadDraft(): boolean { + if (this.storage === null) { + return false; + } + + const document = loadSceneDocumentDraft(this.storage, this.storageKey); + + if (document === null) { + return false; + } + + this.replaceDocument(document); + return true; + } + + exportDocumentJson(): string { + return serializeSceneDocument(this.document); + } + + importDocumentJson(source: string): SceneDocument { + const document = parseSceneDocumentJson(source); + this.replaceDocument(document); + return document; + } + + private emit() { + for (const listener of this.listeners) { + listener(); + } + } +} + +export function createEditorStore(options?: EditorStoreOptions): EditorStore { + return new EditorStore(options); +} diff --git a/src/app/use-editor-store.ts b/src/app/use-editor-store.ts new file mode 100644 index 00000000..a4e02ccd --- /dev/null +++ b/src/app/use-editor-store.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; + +import type { EditorStore, EditorStoreState } from "./editor-store"; + +export function useEditorStoreState(store: EditorStore): EditorStoreState { + return useSyncExternalStore(store.subscribe, store.getState, store.getState); +} diff --git a/src/assets/.gitkeep b/src/assets/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/commands/command-history.ts b/src/commands/command-history.ts new file mode 100644 index 00000000..7c51295a --- /dev/null +++ b/src/commands/command-history.ts @@ -0,0 +1,49 @@ +import type { CommandContext, EditorCommand } from "./command"; + +export class CommandHistory { + private readonly undoStack: EditorCommand[] = []; + private readonly redoStack: EditorCommand[] = []; + + execute(command: EditorCommand, context: CommandContext) { + command.execute(context); + this.undoStack.push(command); + this.redoStack.length = 0; + } + + undo(context: CommandContext): EditorCommand | null { + const command = this.undoStack.pop(); + + if (command === undefined) { + return null; + } + + command.undo(context); + this.redoStack.push(command); + return command; + } + + redo(context: CommandContext): EditorCommand | null { + const command = this.redoStack.pop(); + + if (command === undefined) { + return null; + } + + command.execute(context); + this.undoStack.push(command); + return command; + } + + clear() { + this.undoStack.length = 0; + this.redoStack.length = 0; + } + + canUndo(): boolean { + return this.undoStack.length > 0; + } + + canRedo(): boolean { + return this.redoStack.length > 0; + } +} diff --git a/src/commands/command.ts b/src/commands/command.ts new file mode 100644 index 00000000..e1aeb2d2 --- /dev/null +++ b/src/commands/command.ts @@ -0,0 +1,19 @@ +import type { EditorSelection } from "../core/selection"; +import type { ToolMode } from "../core/tool-mode"; +import type { SceneDocument } from "../document/scene-document"; + +export interface CommandContext { + getDocument(): SceneDocument; + setDocument(document: SceneDocument): void; + getSelection(): EditorSelection; + setSelection(selection: EditorSelection): void; + getToolMode(): ToolMode; + setToolMode(toolMode: ToolMode): void; +} + +export interface EditorCommand { + id: string; + label: string; + execute(context: CommandContext): void; + undo(context: CommandContext): void; +} diff --git a/src/commands/set-scene-name-command.ts b/src/commands/set-scene-name-command.ts new file mode 100644 index 00000000..2c6ae5a3 --- /dev/null +++ b/src/commands/set-scene-name-command.ts @@ -0,0 +1,36 @@ +import { createOpaqueId } from "../core/ids"; + +import type { EditorCommand } from "./command"; + +export function createSetSceneNameCommand(nextName: string): EditorCommand { + const normalizedName = nextName.trim() || "Untitled Scene"; + let previousName: string | null = null; + + return { + id: createOpaqueId("command"), + label: `Rename scene to ${normalizedName}`, + execute(context) { + const currentDocument = context.getDocument(); + + if (previousName === null) { + previousName = currentDocument.name; + } + + context.setDocument({ + ...currentDocument, + name: normalizedName + }); + }, + undo(context) { + if (previousName === null) { + return; + } + + const currentDocument = context.getDocument(); + context.setDocument({ + ...currentDocument, + name: previousName + }); + } + }; +} diff --git a/src/core/ids.ts b/src/core/ids.ts new file mode 100644 index 00000000..6c9ced75 --- /dev/null +++ b/src/core/ids.ts @@ -0,0 +1,10 @@ +let fallbackCounter = 0; + +export function createOpaqueId(prefix: string): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + + fallbackCounter += 1; + return `${prefix}-${Date.now()}-${fallbackCounter}`; +} diff --git a/src/core/selection.ts b/src/core/selection.ts new file mode 100644 index 00000000..44efcd0d --- /dev/null +++ b/src/core/selection.ts @@ -0,0 +1,5 @@ +export type EditorSelection = + | { kind: "none" } + | { kind: "brushes"; ids: string[] } + | { kind: "entities"; ids: string[] } + | { kind: "modelInstances"; ids: string[] }; diff --git a/src/core/tool-mode.ts b/src/core/tool-mode.ts new file mode 100644 index 00000000..a43af7ef --- /dev/null +++ b/src/core/tool-mode.ts @@ -0,0 +1 @@ +export type ToolMode = "select" | "box-create" | "play"; diff --git a/src/core/vector.ts b/src/core/vector.ts new file mode 100644 index 00000000..042ea117 --- /dev/null +++ b/src/core/vector.ts @@ -0,0 +1,11 @@ +export interface Vec3 { + x: number; + y: number; + z: number; +} + +export const DEFAULT_SUN_DIRECTION: Vec3 = { + x: -0.6, + y: 1, + z: 0.35 +}; diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts new file mode 100644 index 00000000..8e09228c --- /dev/null +++ b/src/document/migrate-scene-document.ts @@ -0,0 +1,114 @@ +import { SCENE_DOCUMENT_VERSION, type SceneDocument, type WorldSettings } from "./scene-document"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function expectFiniteNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + + return value; +} + +function expectString(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string.`); + } + + return value; +} + +function expectHexColor(value: unknown, label: string): string { + const normalizedValue = expectString(value, label); + + if (!/^#[0-9a-f]{6}$/i.test(normalizedValue)) { + throw new Error(`${label} must use #RRGGBB format.`); + } + + return normalizedValue; +} + +function expectEmptyCollection(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`${label} must be a record.`); + } + + if (Object.keys(value).length > 0) { + throw new Error(`${label} must be empty in the foundation schema.`); + } + + return {}; +} + +function readWorldSettings(value: unknown): WorldSettings { + if (!isRecord(value)) { + throw new Error("world must be an object."); + } + + const background = value.background; + const ambientLight = value.ambientLight; + const sunLight = value.sunLight; + + if (!isRecord(background)) { + throw new Error("world.background must be an object."); + } + + if (!isRecord(ambientLight)) { + throw new Error("world.ambientLight must be an object."); + } + + if (!isRecord(sunLight)) { + throw new Error("world.sunLight must be an object."); + } + + const direction = sunLight.direction; + + if (!isRecord(direction)) { + throw new Error("world.sunLight.direction must be an object."); + } + + return { + background: { + mode: "solid", + colorHex: expectHexColor(background.colorHex, "world.background.colorHex") + }, + ambientLight: { + colorHex: expectHexColor(ambientLight.colorHex, "world.ambientLight.colorHex"), + intensity: expectFiniteNumber(ambientLight.intensity, "world.ambientLight.intensity") + }, + sunLight: { + colorHex: expectHexColor(sunLight.colorHex, "world.sunLight.colorHex"), + intensity: expectFiniteNumber(sunLight.intensity, "world.sunLight.intensity"), + direction: { + x: expectFiniteNumber(direction.x, "world.sunLight.direction.x"), + y: expectFiniteNumber(direction.y, "world.sunLight.direction.y"), + z: expectFiniteNumber(direction.z, "world.sunLight.direction.z") + } + } + }; +} + +export function migrateSceneDocument(source: unknown): SceneDocument { + if (!isRecord(source)) { + throw new Error("Scene document must be a JSON object."); + } + + if (source.version !== SCENE_DOCUMENT_VERSION) { + throw new Error(`Unsupported scene document version: ${String(source.version)}.`); + } + + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials: expectEmptyCollection(source.materials, "materials"), + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: expectEmptyCollection(source.brushes, "brushes"), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: expectEmptyCollection(source.entities, "entities"), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; +} diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts new file mode 100644 index 00000000..b1944af5 --- /dev/null +++ b/src/document/scene-document.ts @@ -0,0 +1,71 @@ +import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector"; + +export const SCENE_DOCUMENT_VERSION = 1 as const; + +export interface WorldBackgroundSettings { + mode: "solid"; + colorHex: string; +} + +export interface WorldAmbientLightSettings { + colorHex: string; + intensity: number; +} + +export interface WorldSunLightSettings { + colorHex: string; + intensity: number; + direction: Vec3; +} + +export interface WorldSettings { + background: WorldBackgroundSettings; + ambientLight: WorldAmbientLightSettings; + sunLight: WorldSunLightSettings; +} + +export interface SceneDocument { + version: typeof SCENE_DOCUMENT_VERSION; + name: string; + world: WorldSettings; + materials: Record; + textures: Record; + assets: Record; + brushes: Record; + modelInstances: Record; + entities: Record; + interactionLinks: Record; +} + +export function createDefaultWorldSettings(): WorldSettings { + return { + background: { + mode: "solid", + colorHex: "#2f3947" + }, + ambientLight: { + colorHex: "#f7f1e8", + intensity: 1 + }, + sunLight: { + colorHex: "#fff1d5", + intensity: 1.75, + direction: DEFAULT_SUN_DIRECTION + } + }; +} + +export function createEmptySceneDocument(overrides: Partial> = {}): SceneDocument { + return { + version: SCENE_DOCUMENT_VERSION, + name: overrides.name ?? "Untitled Scene", + world: overrides.world ?? createDefaultWorldSettings(), + materials: {}, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }; +} diff --git a/src/entities/.gitkeep b/src/entities/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/entities/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/geometry/.gitkeep b/src/geometry/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/geometry/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 00000000..c34f9610 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./app/App"; +import "./app/app.css"; +import { createEditorStore } from "./app/editor-store"; +import { getBrowserStorage, loadOrCreateSceneDocument } from "./serialization/local-draft-storage"; + +const rootElement = document.getElementById("root"); + +if (rootElement === null) { + throw new Error("Expected #root element to bootstrap the editor."); +} + +const storage = getBrowserStorage(); +const editorStore = createEditorStore({ + initialDocument: loadOrCreateSceneDocument(storage), + storage +}); + +ReactDOM.createRoot(rootElement).render( + + + +); diff --git a/src/materials/.gitkeep b/src/materials/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/materials/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/runtime-three/.gitkeep b/src/runtime-three/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/runtime-three/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/serialization/local-draft-storage.ts b/src/serialization/local-draft-storage.ts new file mode 100644 index 00000000..e6bea3b5 --- /dev/null +++ b/src/serialization/local-draft-storage.ts @@ -0,0 +1,45 @@ +import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document"; + +import { parseSceneDocumentJson, serializeSceneDocument } from "./scene-document-json"; + +export interface KeyValueStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft"; + +export function getBrowserStorage(): KeyValueStorage | null { + if (typeof window === "undefined") { + return null; + } + + return window.localStorage; +} + +export function saveSceneDocumentDraft( + storage: KeyValueStorage, + document: SceneDocument, + key = DEFAULT_SCENE_DRAFT_STORAGE_KEY +) { + storage.setItem(key, serializeSceneDocument(document)); +} + +export function loadSceneDocumentDraft(storage: KeyValueStorage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument | null { + const rawDocument = storage.getItem(key); + + if (rawDocument === null) { + return null; + } + + return parseSceneDocumentJson(rawDocument); +} + +export function loadOrCreateSceneDocument(storage: KeyValueStorage | null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument { + if (storage === null) { + return createEmptySceneDocument(); + } + + return loadSceneDocumentDraft(storage, key) ?? createEmptySceneDocument(); +} diff --git a/src/serialization/scene-document-json.ts b/src/serialization/scene-document-json.ts new file mode 100644 index 00000000..3f7eb36b --- /dev/null +++ b/src/serialization/scene-document-json.ts @@ -0,0 +1,19 @@ +import type { SceneDocument } from "../document/scene-document"; +import { migrateSceneDocument } from "../document/migrate-scene-document"; + +export function serializeSceneDocument(document: SceneDocument): string { + return JSON.stringify(document, null, 2); +} + +export function parseSceneDocumentJson(source: string): SceneDocument { + let parsedValue: unknown; + + try { + parsedValue = JSON.parse(source); + } catch (error) { + const cause = error instanceof Error ? error.message : "Unknown JSON parse failure."; + throw new Error(`Scene document JSON could not be parsed: ${cause}`); + } + + return migrateSceneDocument(parsedValue); +} diff --git a/src/shared-ui/Panel.tsx b/src/shared-ui/Panel.tsx new file mode 100644 index 00000000..cefa893b --- /dev/null +++ b/src/shared-ui/Panel.tsx @@ -0,0 +1,14 @@ +import type { PropsWithChildren } from "react"; + +interface PanelProps extends PropsWithChildren { + title: string; +} + +export function Panel({ title, children }: PanelProps) { + return ( +
+
{title}
+
{children}
+
+ ); +} diff --git a/src/viewport-three/ViewportCanvas.tsx b/src/viewport-three/ViewportCanvas.tsx new file mode 100644 index 00000000..557200db --- /dev/null +++ b/src/viewport-three/ViewportCanvas.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; + +import type { WorldSettings } from "../document/scene-document"; + +import { ViewportHost } from "./viewport-host"; + +interface ViewportCanvasProps { + world: WorldSettings; +} + +export function ViewportCanvas({ world }: ViewportCanvasProps) { + const containerRef = useRef(null); + const hostRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + + if (container === null) { + return; + } + + const viewportHost = new ViewportHost(); + hostRef.current = viewportHost; + viewportHost.mount(container, world); + + return () => { + viewportHost.dispose(); + hostRef.current = null; + }; + }, []); + + useEffect(() => { + hostRef.current?.updateWorld(world); + }, [world]); + + return
; +} diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts new file mode 100644 index 00000000..d8a21396 --- /dev/null +++ b/src/viewport-three/viewport-host.ts @@ -0,0 +1,100 @@ +import { + AmbientLight, + AxesHelper, + Color, + DirectionalLight, + GridHelper, + PerspectiveCamera, + Scene, + Vector3, + WebGLRenderer +} from "three"; + +import type { WorldSettings } from "../document/scene-document"; + +export class ViewportHost { + private readonly scene = new Scene(); + private readonly camera = new PerspectiveCamera(60, 1, 0.1, 1000); + private readonly renderer = new WebGLRenderer({ antialias: true }); + private readonly ambientLight = new AmbientLight(); + private readonly sunLight = new DirectionalLight(); + private resizeObserver: ResizeObserver | null = null; + private animationFrame = 0; + private container: HTMLElement | null = null; + + constructor() { + this.camera.position.set(8, 8, 8); + this.camera.lookAt(new Vector3(0, 0, 0)); + + const gridHelper = new GridHelper(40, 40, 0xc68d67, 0x4e596b); + const axesHelper = new AxesHelper(2); + + this.scene.add(gridHelper); + this.scene.add(axesHelper); + this.scene.add(this.ambientLight); + this.scene.add(this.sunLight); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + } + + mount(container: HTMLElement, world: WorldSettings) { + this.container = container; + container.appendChild(this.renderer.domElement); + this.updateWorld(world); + this.resize(); + + this.resizeObserver = new ResizeObserver(() => { + this.resize(); + }); + this.resizeObserver.observe(container); + + this.render(); + } + + updateWorld(world: WorldSettings) { + this.scene.background = new Color(world.background.colorHex); + this.ambientLight.color.set(world.ambientLight.colorHex); + this.ambientLight.intensity = world.ambientLight.intensity; + this.sunLight.color.set(world.sunLight.colorHex); + this.sunLight.intensity = world.sunLight.intensity; + this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18); + } + + dispose() { + if (this.animationFrame !== 0) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + } + + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.renderer.dispose(); + + if (this.container !== null && this.container.contains(this.renderer.domElement)) { + this.container.removeChild(this.renderer.domElement); + } + + this.container = null; + } + + private resize() { + if (this.container === null) { + return; + } + + const width = this.container.clientWidth; + const height = this.container.clientHeight; + + if (width === 0 || height === 0) { + return; + } + + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + + private render = () => { + this.animationFrame = window.requestAnimationFrame(this.render); + this.renderer.render(this.scene, this.camera); + }; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/domain/create-empty-scene-document.test.ts b/tests/domain/create-empty-scene-document.test.ts new file mode 100644 index 00000000..756da148 --- /dev/null +++ b/tests/domain/create-empty-scene-document.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document"; + +describe("createEmptySceneDocument", () => { + it("creates a versioned empty scene document", () => { + const document = createEmptySceneDocument(); + + expect(document.version).toBe(SCENE_DOCUMENT_VERSION); + expect(document.name).toBe("Untitled Scene"); + expect(document.brushes).toEqual({}); + expect(document.entities).toEqual({}); + expect(document.modelInstances).toEqual({}); + }); +}); diff --git a/tests/domain/editor-store.test.ts b/tests/domain/editor-store.test.ts new file mode 100644 index 00000000..6349def5 --- /dev/null +++ b/tests/domain/editor-store.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { createEditorStore } from "../../src/app/editor-store"; +import { createSetSceneNameCommand } from "../../src/commands/set-scene-name-command"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import type { KeyValueStorage } from "../../src/serialization/local-draft-storage"; + +class MemoryStorage implements KeyValueStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +describe("EditorStore", () => { + it("applies command history with undo and redo", () => { + const store = createEditorStore(); + + store.executeCommand(createSetSceneNameCommand("Foundation Room")); + + expect(store.getState().document.name).toBe("Foundation Room"); + expect(store.getState().canUndo).toBe(true); + + expect(store.undo()).toBe(true); + expect(store.getState().document.name).toBe("Untitled Scene"); + expect(store.getState().canRedo).toBe(true); + + expect(store.redo()).toBe(true); + expect(store.getState().document.name).toBe("Foundation Room"); + }); + + it("saves and loads a local draft document", () => { + const storage = new MemoryStorage(); + const writerStore = createEditorStore({ storage }); + + writerStore.executeCommand(createSetSceneNameCommand("Draft Scene")); + expect(writerStore.saveDraft()).toBe(true); + + const readerStore = createEditorStore({ + initialDocument: createEmptySceneDocument({ name: "Fresh Scene" }), + storage + }); + + expect(readerStore.loadDraft()).toBe(true); + expect(readerStore.getState().document.name).toBe("Draft Scene"); + }); +}); diff --git a/tests/e2e/app-smoke.e2e.ts b/tests/e2e/app-smoke.e2e.ts new file mode 100644 index 00000000..8632d6f4 --- /dev/null +++ b/tests/e2e/app-smoke.e2e.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test("app boots and shows the viewport shell", async ({ page }) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + + await page.goto("/"); + + await expect(page.getByText("WebEditor3D")).toBeVisible(); + await expect(page.getByTestId("viewport-shell")).toBeVisible(); + + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts new file mode 100644 index 00000000..9631b989 --- /dev/null +++ b/tests/serialization/scene-document-json.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { migrateSceneDocument } from "../../src/document/migrate-scene-document"; +import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json"; + +describe("scene document JSON", () => { + it("round-trips the current empty schema", () => { + const document = createEmptySceneDocument({ name: "Bootstrap Scene" }); + const serializedDocument = serializeSceneDocument(document); + + expect(parseSceneDocumentJson(serializedDocument)).toEqual(document); + }); + + it("rejects unsupported versions", () => { + expect(() => + migrateSceneDocument({ + version: 99, + name: "Legacy", + world: {}, + materials: {}, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }) + ).toThrow("Unsupported scene document version"); + }); +}); diff --git a/tests/setup/vitest.setup.ts b/tests/setup/vitest.setup.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/tests/setup/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..5ea6db5b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "react-jsx", + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": ["src", "tests", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..ce28aaa3 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 5173 + } +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..9c7afdf0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, mergeConfig } from "vitest/config"; + +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./tests/setup/vitest.setup.ts"], + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"] + } + }) +);