mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-07 02:21:23 +00:00
artcraft: move actions/ + hooks/ + DragAndDrop/ + contexts/ into ui-pagescene
Lib gains the React/orchestration layer that sits between the engine
and the host React tree:
- src/lib/actions/* (12 dispatchers + index): addCharacter, addObject,
addShape, deleteObject, selectObject, setCameraAspect,
setObjectColor (+ ColorSession), setTransformMode, toggleObjectLock,
toggleObjectVisibility, transformObject (+ TransformSession),
deselectObject, beginColorSession, beginTransformSession.
- src/lib/hooks/*: useEditorCanvas, useCamViewCanvas, useFreeCam,
useViewportPointer, useViewportKeyboard. ~/-aliased imports
rewritten to relative paths inside the lib.
- src/lib/contexts/EngineContext/{EngineContext,EngineProvider}: the
React context boundary. EngineProvider takes adapter + cache hooks
via props and constructs `new Editor(adapter)`.
- src/lib/DragAndDrop/DndAsset.ts: the asset-drop singleton, now
reading viewport size through `editor.adapter.getViewportSize?.()`
with a `window.innerWidth/innerHeight` fallback when the host
doesn't supply one.
- src/lib/sceneMetadata.ts: the SceneGenereationMetaData helper used
by the save flow + the EngineProvider cache snapshot.
Adapter interface gains:
- getViewportSize?(): { width: number; height: number }
Used by DndAsset for drop hit-testing. Optional — if the host
omits it, the lib falls back to window.
Apps wrappers:
- All apps copies become one-line `export { X } from "@storyteller/
ui-pagescene"` re-export shims so existing importers (PageEditor,
AssetMenu items, ImageTo3DExperience, hooks, EngineContext
consumers) keep their import paths unchanged.
- useTauriPageSceneAdapter.ts now wires getViewportSize from the
existing pageWidth/pageHeight signals so the artcraft host's
TopBar height + page chrome are honored.
Public API (src/index.ts) adds:
- All 12 action dispatchers + ColorSession/TransformSession types.
- The 4 hooks (useEditorCanvas, useCamViewCanvas, useFreeCam,
useViewportPointer, useViewportKeyboard).
- EngineContext + setActiveEditor/getActiveEditor + EditorExpandedI.
- EngineProvider.
- dragAndDrop singleton.
- getSceneGenerationMetaData.
Verified: artcraft typecheck holds at 72 errors (all pre-existing,
mostly in comps/ and unrelated areas like SoundManager). Lib's own
typecheck cycle through `noEmitOnError: false` produces a clean
dist/index.d.ts with the new public surface.
Remaining for Stage B: comps/ (15 React UI components), lifting the
12-tab switch out of PageEditor.tsx, nx build smoke + manual end-
to-end test.
This commit is contained in:
1 parent
3d646b8f19
commit
8250aa0a05
44 files changed
+1001
-893
No files matched your search
@@ -1,158 +1,2 @@
|
||||
import React from "react";
|
||||
import { MediaItem } from "~/pages/PageScene/models";
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
import { pageHeight, pageWidth } from "~/signals";
|
||||
import { AssetType } from "~/enums";
|
||||
import type Editor from "~/pages/PageScene/engine/editor";
|
||||
import { pickDropPosition } from "~/pages/PageScene/engine/pickDropPosition";
|
||||
import {
|
||||
addCharacter,
|
||||
addObject,
|
||||
addShape,
|
||||
} from "~/pages/PageScene/actions";
|
||||
|
||||
class DndAsset {
|
||||
public dropId: string = "";
|
||||
public overElement: DOMRect | null = null;
|
||||
public dropOffset = 0;
|
||||
public initX = 0;
|
||||
public initY = 0;
|
||||
public notDropText = "";
|
||||
public isDragging: boolean = false;
|
||||
public dragThreshold: number = 5;
|
||||
private editor: Editor | null = null;
|
||||
|
||||
constructor() {
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
item: MediaItem,
|
||||
editor: Editor | null,
|
||||
) {
|
||||
if (event.button === 0) {
|
||||
this.editor = editor;
|
||||
const store = usePageSceneStore.getState();
|
||||
store.setDragItem(item);
|
||||
store.setDragPosition({ currX: event.pageX, currY: event.pageY });
|
||||
this.initX = event.pageX;
|
||||
this.initY = event.pageY;
|
||||
this.isDragging = false;
|
||||
store.setCanDrop(false);
|
||||
this.notDropText = "";
|
||||
store.setAssetModalVisibleDuringDrag(false);
|
||||
window.addEventListener("pointerup", this.onPointerUp);
|
||||
window.addEventListener("pointermove", this.onPointerMove);
|
||||
}
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
const store = usePageSceneStore.getState();
|
||||
if (store.dragItem) {
|
||||
store.setDragItem(null);
|
||||
store.setCanDrop(false);
|
||||
this.overElement = null;
|
||||
this.notDropText = "";
|
||||
store.setAssetModalVisibleDuringDrag(store.reopenAfterDrag);
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
overCanvas(positionX: number, positionY: number) {
|
||||
if (positionY < 69) {
|
||||
return false;
|
||||
}
|
||||
if (positionY > pageHeight.value) {
|
||||
return false;
|
||||
}
|
||||
return positionX <= pageWidth.value;
|
||||
}
|
||||
|
||||
onPointerUp(event: PointerEvent) {
|
||||
window.removeEventListener("pointerup", this.onPointerUp);
|
||||
window.removeEventListener("pointermove", this.onPointerMove);
|
||||
|
||||
const store = usePageSceneStore.getState();
|
||||
if (!this.isDragging) {
|
||||
store.setAssetModalVisibleDuringDrag(true);
|
||||
store.setDragItem(null);
|
||||
store.setDragPosition({ currX: 0, currY: 0 });
|
||||
this.editor = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = this.editor;
|
||||
const mediaItem = store.dragItem;
|
||||
if (mediaItem && editor) {
|
||||
const positionX = event.pageX;
|
||||
const positionY = event.pageY;
|
||||
if (this.overCanvas(positionX, positionY)) {
|
||||
const worldPosition = pickDropPosition(
|
||||
{
|
||||
getCamera: () => editor.cameraController.camera,
|
||||
getCanvas: () => editor.renderer?.domElement,
|
||||
getRaycastTargets: () => editor.activeScene.scene.children,
|
||||
removeTransformControls: () =>
|
||||
editor.utils.removeTransformControls(true),
|
||||
},
|
||||
positionX,
|
||||
positionY,
|
||||
);
|
||||
if (mediaItem.type === AssetType.CHARACTER) {
|
||||
void addCharacter(editor, mediaItem, worldPosition);
|
||||
} else if (
|
||||
mediaItem.type === AssetType.OBJECT ||
|
||||
mediaItem.type === AssetType.SPLAT ||
|
||||
mediaItem.type === AssetType.SKYBOX
|
||||
) {
|
||||
void addObject(editor, mediaItem, worldPosition);
|
||||
} else if (mediaItem.type === AssetType.SHAPE) {
|
||||
void addShape(editor, mediaItem, worldPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
onPointerMove(event: MouseEvent) {
|
||||
const store = usePageSceneStore.getState();
|
||||
if (store.dragItem) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const deltaX = event.pageX - this.initX;
|
||||
const deltaY = event.pageY - this.initY;
|
||||
if (
|
||||
Math.abs(deltaX) > this.dragThreshold ||
|
||||
Math.abs(deltaY) > this.dragThreshold
|
||||
) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
store.setDragPosition({
|
||||
currX: this.initX + deltaX,
|
||||
currY: this.initY + deltaY,
|
||||
});
|
||||
if (this.overElement) {
|
||||
const pos = this.overElement;
|
||||
const eventY = event.pageY;
|
||||
const inHeight = eventY >= pos.top && eventY <= pos.top + pos.height;
|
||||
const eventX = event.pageX;
|
||||
const inWidth = eventX >= pos.left && eventX <= pos.left + pos.width;
|
||||
|
||||
if (inHeight && inWidth) {
|
||||
return;
|
||||
}
|
||||
store.setCanDrop(false);
|
||||
this.dropId = "";
|
||||
this.overElement = null;
|
||||
this.notDropText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dragAndDrop = new DndAsset();
|
||||
|
||||
import { dragAndDrop } from "@storyteller/ui-pagescene";
|
||||
export default dragAndDrop;
|
||||
@@ -1,33 +1 @@
|
||||
import * as THREE from "three";
|
||||
import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export async function addCharacter(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
const obj = await editor.sceneManager?.create(
|
||||
item.media_id,
|
||||
item.name ?? "character",
|
||||
position ?? new THREE.Vector3(),
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
|
||||
obj.userData.isCharacter = true;
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: "character",
|
||||
name: obj.name || (item.name ?? "character"),
|
||||
mediaId: item.media_id,
|
||||
}),
|
||||
);
|
||||
editor.selection.refreshOutliner();
|
||||
return obj.uuid;
|
||||
}
|
||||
export { addCharacter } from "@storyteller/ui-pagescene";
|
||||
@@ -1,31 +1 @@
|
||||
import * as THREE from "three";
|
||||
import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export async function addObject(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
const obj = await editor.sceneManager?.create(
|
||||
item.media_id,
|
||||
item.name ?? "object",
|
||||
position ?? new THREE.Vector3(),
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: "object",
|
||||
name: obj.name || (item.name ?? "object"),
|
||||
mediaId: item.media_id,
|
||||
}),
|
||||
);
|
||||
editor.selection.refreshOutliner();
|
||||
return obj.uuid;
|
||||
}
|
||||
export { addObject } from "@storyteller/ui-pagescene";
|
||||
@@ -1,36 +1 @@
|
||||
import * as THREE from "three";
|
||||
import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export async function addShape(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
const obj = await editor.sceneManager?.create(
|
||||
"Parim",
|
||||
item.media_id,
|
||||
position ?? new THREE.Vector3(),
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
// Stash the geometry key (Box / Sphere / PointLight / ...) so undo/redo
|
||||
// can re-route through scene.instantiate's name switch. obj.name gets
|
||||
// overridden below with the display label ("Cube", "Point Light").
|
||||
obj.userData.shapeKey = item.media_id;
|
||||
obj.name = item.name ?? "shape";
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: "shape",
|
||||
name: obj.name,
|
||||
mediaId: item.media_id,
|
||||
}),
|
||||
);
|
||||
editor.selection.refreshOutliner();
|
||||
return obj.uuid;
|
||||
}
|
||||
export { addShape } from "@storyteller/ui-pagescene";
|
||||
@@ -1,10 +1 @@
|
||||
import type Editor from "../engine/editor";
|
||||
|
||||
// Editor.deleteObject already removes the THREE.js object via SceneUtils,
|
||||
// which emits ObjectRemovedEvent + InspectorPanelChangedEvent on the
|
||||
// engine bus; the bridge translates those into the matching store
|
||||
// mutations. This action exists for symmetry with addObject and to
|
||||
// give callers a single import surface.
|
||||
export function deleteObject(editor: Editor, id: string): void {
|
||||
editor.deleteObject(id);
|
||||
}
|
||||
export { deleteObject } from "@storyteller/ui-pagescene";
|
||||
@@ -1,15 +1 @@
|
||||
import type Editor from "../engine/editor";
|
||||
|
||||
// Selecting in the engine triggers Editor.publishSelect which writes the
|
||||
// selection into the store, so this action only needs to invoke the engine
|
||||
// path.
|
||||
export function selectObject(editor: Editor, id: string): void {
|
||||
editor.sceneManager?.select_object(id);
|
||||
}
|
||||
|
||||
export function deselectObject(editor: Editor): void {
|
||||
if (editor.sceneManager) {
|
||||
editor.sceneManager.selected_objects = undefined;
|
||||
}
|
||||
editor.selection.publishSelect();
|
||||
}
|
||||
export { selectObject, deselectObject } from "@storyteller/ui-pagescene";
|
||||
@@ -1,11 +1 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { CameraAspectRatio } from "../enums";
|
||||
import { CameraAspectRatioChangedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export function setCameraAspect(
|
||||
editor: Editor,
|
||||
ratio: CameraAspectRatio,
|
||||
): void {
|
||||
editor.cameraController.changeRenderCameraAspectRatio(ratio);
|
||||
editor.bus.emit(new CameraAspectRatioChangedEvent(ratio));
|
||||
}
|
||||
export { setCameraAspect } from "@storyteller/ui-pagescene";
|
||||
@@ -1,58 +1,2 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { ColorAction } from "../engine/editor/actions/ColorAction";
|
||||
|
||||
// One-shot apply: use when the change is atomic (e.g. a button click
|
||||
// or programmatic setter). Records a single ColorAction.
|
||||
//
|
||||
// For interactive flows where a single user gesture fires many
|
||||
// intermediate values (the native <input type="color"> dialog spams
|
||||
// onChange per slider pixel), use beginColorSession instead — it
|
||||
// applies each intermediate value to the engine for visual feedback
|
||||
// but records only ONE ColorAction on commit.
|
||||
export function setObjectColor(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
color: string,
|
||||
): void {
|
||||
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (!obj) return;
|
||||
const before = (obj.userData.color as string) ?? "#ffffff";
|
||||
if (before === color) return;
|
||||
editor.activeScene.setColor(uuid, color);
|
||||
editor.history.record(new ColorAction(editor, uuid, before, color));
|
||||
}
|
||||
|
||||
// Session-style color edit: snapshots the before-state at construction
|
||||
// (focus / picker open), applies each intermediate value to the engine
|
||||
// (visual feedback during slider drag), and records exactly one
|
||||
// ColorAction on commit (focus loss / picker close). No-op on commit
|
||||
// if the final color matches the before-state.
|
||||
export type ColorSession = {
|
||||
apply(color: string): void;
|
||||
commit(): void;
|
||||
};
|
||||
|
||||
export function beginColorSession(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
): ColorSession {
|
||||
const startObj = editor.activeScene.scene.getObjectByProperty(
|
||||
"uuid",
|
||||
uuid,
|
||||
);
|
||||
const before = (startObj?.userData.color as string) ?? "#ffffff";
|
||||
return {
|
||||
apply(color: string) {
|
||||
editor.activeScene.setColor(uuid, color);
|
||||
},
|
||||
commit() {
|
||||
const endObj = editor.activeScene.scene.getObjectByProperty(
|
||||
"uuid",
|
||||
uuid,
|
||||
);
|
||||
const after = (endObj?.userData.color as string) ?? before;
|
||||
if (before === after) return;
|
||||
editor.history.record(new ColorAction(editor, uuid, before, after));
|
||||
},
|
||||
};
|
||||
}
|
||||
export { setObjectColor, beginColorSession } from "@storyteller/ui-pagescene";
|
||||
export type { ColorSession } from "@storyteller/ui-pagescene";
|
||||
@@ -1,18 +1 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import type { TransformMode } from "../PageSceneStore";
|
||||
import {
|
||||
SelectedModeChangedEvent,
|
||||
TransformModeChangedEvent,
|
||||
} from "../engine/events/EngineEvent";
|
||||
|
||||
const ENGINE_MODE: Record<TransformMode, "translate" | "rotate" | "scale"> = {
|
||||
move: "translate",
|
||||
rotate: "rotate",
|
||||
scale: "scale",
|
||||
};
|
||||
|
||||
export function setTransformMode(editor: Editor, mode: TransformMode): void {
|
||||
editor.gizmo.changeMode(ENGINE_MODE[mode]);
|
||||
editor.bus.emit(new TransformModeChangedEvent(mode));
|
||||
editor.bus.emit(new SelectedModeChangedEvent(mode));
|
||||
}
|
||||
export { setTransformMode } from "@storyteller/ui-pagescene";
|
||||
@@ -1,18 +1 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { LockAction } from "../engine/editor/actions/LockAction";
|
||||
import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
// Toggle the locked state of an object: flips userData.locked, runs
|
||||
// the gizmo attach/detach side effect, syncs the outliner row icon in
|
||||
// the Zustand store, and records the toggle on the undo stack.
|
||||
//
|
||||
// View callers (Outliner) only need to provide editor + uuid; the
|
||||
// action handles the engine + store + history orchestration.
|
||||
export function toggleObjectLock(editor: Editor, uuid: string): void {
|
||||
const before = editor.selection.isObjectLocked(uuid);
|
||||
editor.bus.emit(new OutlinerItemLockToggledEvent(uuid));
|
||||
editor.selection.lockUnlockObject(uuid);
|
||||
editor.history.record(
|
||||
new LockAction(editor.selection, uuid, before, !before),
|
||||
);
|
||||
}
|
||||
export { toggleObjectLock } from "@storyteller/ui-pagescene";
|
||||
@@ -1,21 +1 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { VisibilityAction } from "../engine/editor/actions/VisibilityAction";
|
||||
import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
// Toggle the visibility of an object: flips obj.visible +
|
||||
// userData.visible, syncs the outliner row icon in the Zustand store,
|
||||
// and records the toggle on the undo stack.
|
||||
//
|
||||
// View callers (Outliner) only need to provide editor + uuid; the
|
||||
// helper reads the before-state from the live engine object so the
|
||||
// view doesn't have to plumb it.
|
||||
export function toggleObjectVisibility(editor: Editor, uuid: string): void {
|
||||
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (!obj) return;
|
||||
const before = obj.visible;
|
||||
editor.bus.emit(new OutlinerItemVisibilityToggledEvent(uuid));
|
||||
editor.sceneManager?.hideObject(uuid);
|
||||
editor.history.record(
|
||||
new VisibilityAction(editor, uuid, before, !before),
|
||||
);
|
||||
}
|
||||
export { toggleObjectVisibility } from "@storyteller/ui-pagescene";
|
||||
@@ -1,39 +1,2 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { TransformAction } from "../engine/editor/actions/TransformAction";
|
||||
|
||||
// A transform "session" — opened on the first edit of a multi-input
|
||||
// editing flow (e.g. Object Panel position/rotation/scale text fields),
|
||||
// committed at the end of the session (selection change, panel
|
||||
// unmount, focus moving away from the panel).
|
||||
//
|
||||
// The session captures the before-state at construction. On commit it
|
||||
// captures the after-state, pushes a single TransformAction onto the
|
||||
// undo stack only if the transform actually changed, and re-syncs the
|
||||
// Zustand `objectPanel` slice from the engine so the panel's view of
|
||||
// the selection matches the engine state.
|
||||
//
|
||||
// Engine-level concerns (capture / apply / revert) stay in
|
||||
// `engine/editor/actions/TransformAction.ts`; this file is the
|
||||
// orchestration wrapper that the action layer (UI code) uses so the
|
||||
// view never has to construct an UndoableAction directly.
|
||||
export type TransformSession = {
|
||||
commit(): void;
|
||||
};
|
||||
|
||||
export function beginTransformSession(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
): TransformSession {
|
||||
const action = new TransformAction(editor, uuid);
|
||||
return {
|
||||
commit() {
|
||||
if (action.commit()) {
|
||||
editor.history.record(action);
|
||||
}
|
||||
// Push the engine's post-edit transform back into the Zustand
|
||||
// objectPanel slice so the panel's currentSceneObject vectors
|
||||
// are in sync with what the user just committed. Idempotent.
|
||||
editor.selection.updateSelectedUI();
|
||||
},
|
||||
};
|
||||
}
|
||||
export { beginTransformSession } from "@storyteller/ui-pagescene";
|
||||
export type { TransformSession } from "@storyteller/ui-pagescene";
|
||||
+6
-26
@@ -1,26 +1,6 @@
|
||||
import { createContext } from "react";
|
||||
import Editor from "~/pages/PageScene/engine/editor";
|
||||
|
||||
export interface EditorExpandedI extends Editor {
|
||||
setSceneContainer: React.Dispatch<
|
||||
React.SetStateAction<HTMLDivElement | null>
|
||||
>;
|
||||
setEditorCanvas: React.Dispatch<
|
||||
React.SetStateAction<HTMLCanvasElement | null>
|
||||
>;
|
||||
setCamViewCanvas: React.Dispatch<
|
||||
React.SetStateAction<HTMLCanvasElement | null>
|
||||
>;
|
||||
}
|
||||
|
||||
export const EngineContext = createContext<Editor | null>(null);
|
||||
|
||||
// Module-level handle on the active Editor instance. EngineProvider keeps
|
||||
// this in sync with its lifecycle. React components should still use
|
||||
// useContext(EngineContext) — this exists for non-React callers (and for
|
||||
// cross-page actions that fire before the 3D tab has mounted).
|
||||
let activeEditor: Editor | null = null;
|
||||
export const getActiveEditor = (): Editor | null => activeEditor;
|
||||
export const setActiveEditor = (editor: Editor | null): void => {
|
||||
activeEditor = editor;
|
||||
};
|
||||
export {
|
||||
EngineContext,
|
||||
setActiveEditor,
|
||||
getActiveEditor,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
export type { EditorExpandedI } from "@storyteller/ui-pagescene";
|
||||
+1
-98
@@ -1,98 +1 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { EngineContext, setActiveEditor } from "./EngineContext";
|
||||
|
||||
import {
|
||||
Editor,
|
||||
type PageSceneAdapter,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
import { getSceneGenerationMetaData } from "../../sceneMetadata";
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
|
||||
interface Props {
|
||||
sceneToken?: string;
|
||||
// The platform-abstraction surface the lib's Editor needs. Built by
|
||||
// the artcraft wrapper from Tauri APIs (FetchProxy, GenerateImage,
|
||||
// MediaFilesApi, useTabStore for tab cache, etc.) and threaded down.
|
||||
adapter: PageSceneAdapter;
|
||||
// In-memory restore: if provided, the engine deserializes this on
|
||||
// mount instead of fetching the scene by token. Host-supplied (e.g.
|
||||
// artcraft sources it from useTabStore so tab switches preserve the
|
||||
// scene without an HTTP roundtrip).
|
||||
cacheJsonString?: string;
|
||||
// In-memory save: called on unmount with the serialized scene JSON.
|
||||
// The host stashes it wherever it likes (tabStore, localStorage,
|
||||
// nowhere) — the lib stays storage-agnostic.
|
||||
onSceneSerialized?: (json: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Drives the Editor lifecycle from React mount/unmount + the
|
||||
// availability of the canvas DOM nodes. No tab knowledge — the host
|
||||
// decides when this provider is mounted (e.g. only when the 3D tab is
|
||||
// active). When the canvases unmount, callback refs nulled in
|
||||
// PageSceneStore drive the cleanup branch of this effect.
|
||||
export const EngineProvider = ({
|
||||
sceneToken,
|
||||
adapter,
|
||||
cacheJsonString,
|
||||
onSceneSerialized,
|
||||
children,
|
||||
}: Props) => {
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
// Hold the latest cache + serialize callback in refs so the engine
|
||||
// lifecycle effect can read them in cleanup without re-running on
|
||||
// every prop change. (The editor itself doesn't need a mirror ref —
|
||||
// closure capture of the local `newEditor` handles cleanup.)
|
||||
const cacheRef = useRef(cacheJsonString);
|
||||
cacheRef.current = cacheJsonString;
|
||||
const onSerializeRef = useRef(onSceneSerialized);
|
||||
onSerializeRef.current = onSceneSerialized;
|
||||
// Adapter is read at engine-construction time only; ref guards
|
||||
// against host-side identity changes triggering unwanted rebuilds.
|
||||
const adapterRef = useRef(adapter);
|
||||
adapterRef.current = adapter;
|
||||
|
||||
const sceneContainer = usePageSceneStore((s) => s.sceneContainerEl);
|
||||
const editorCanvas = usePageSceneStore((s) => s.editorCanvasEl);
|
||||
const camViewCanvas = usePageSceneStore((s) => s.camViewCanvasEl);
|
||||
|
||||
useEffect(() => {
|
||||
// Engine construction happens once all three DOM nodes are
|
||||
// available; callback refs in SceneContainer / EditorCanvas /
|
||||
// CameraViewCanvas drive this by setting their nodes (and clearing
|
||||
// them to null on unmount).
|
||||
if (!sceneContainer || !editorCanvas || !camViewCanvas) return;
|
||||
|
||||
const newEditor = new Editor(adapterRef.current);
|
||||
newEditor.initialize({
|
||||
sceneToken: sceneToken || "",
|
||||
sceneContainerEl: sceneContainer,
|
||||
editorCanvasEl: editorCanvas,
|
||||
camViewCanvasEl: camViewCanvas,
|
||||
cacheJsonString: cacheRef.current,
|
||||
});
|
||||
setEditor(newEditor);
|
||||
setActiveEditor(newEditor);
|
||||
|
||||
return () => {
|
||||
// Snapshot scene to host-managed cache so we can restore it on
|
||||
// remount. Skip if the scene never finished loading; the host's
|
||||
// last-known-good cache is preserved on its side.
|
||||
if (newEditor.isEngineDataLoaded()) {
|
||||
const sceneGenerationMetadata = getSceneGenerationMetaData(newEditor);
|
||||
const cacheJson = newEditor.save_manager.getSceneJson({
|
||||
sceneGenerationMetadata,
|
||||
});
|
||||
onSerializeRef.current?.(JSON.stringify(cacheJson));
|
||||
}
|
||||
|
||||
newEditor.unmountEngine();
|
||||
setEditor(null);
|
||||
setActiveEditor(null);
|
||||
};
|
||||
}, [sceneToken, sceneContainer, editorCanvas, camViewCanvas]);
|
||||
|
||||
return (
|
||||
<EngineContext.Provider value={editor}>{children}</EngineContext.Provider>
|
||||
);
|
||||
};
|
||||
export { EngineProvider } from "@storyteller/ui-pagescene";
|
||||
@@ -1,14 +1 @@
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
|
||||
// React-shaped accessors for the canvas DOM nodes that the engine
|
||||
// renders into. The underlying storage is the Zustand store (set by
|
||||
// EngineCanvases on mount); the selector triggers a re-render whenever
|
||||
// the canvas mounts/unmounts.
|
||||
|
||||
export function useEditorCanvas(): HTMLCanvasElement | null {
|
||||
return usePageSceneStore((s) => s.editorCanvasEl);
|
||||
}
|
||||
|
||||
export function useCamViewCanvas(): HTMLCanvasElement | null {
|
||||
return usePageSceneStore((s) => s.camViewCanvasEl);
|
||||
}
|
||||
export { useEditorCanvas, useCamViewCanvas } from "@storyteller/ui-pagescene";
|
||||
@@ -1,164 +1 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MathUtils } from "three";
|
||||
import {
|
||||
createFreeCamControlState,
|
||||
emptyMoveKeys,
|
||||
emptyRotateKeys,
|
||||
moveSlotForKeyCode,
|
||||
panFromDrag,
|
||||
rotateSlotForKeyCode,
|
||||
zoomFromWheel,
|
||||
type FreeCamControlState,
|
||||
} from "~/pages/PageScene/engine/cameraMath";
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
import type Editor from "~/pages/PageScene/engine/editor";
|
||||
|
||||
// Drives the 3D viewport's "fly" camera controls (right-click pan,
|
||||
// wheel zoom, WASD/QE forward/back/strafe/up/down, arrow-key roll/yaw)
|
||||
// for navigating the scene in EDIT mode. CAMERA_VIEW (the virtual-
|
||||
// camera POV preview) re-uses the same input pipeline.
|
||||
//
|
||||
// Pointer + wheel listeners go on the canvas (events naturally route
|
||||
// to canvas regardless of focus). WASD/QE/arrow keys go on document
|
||||
// with an editable-element guard, so the user can fly without
|
||||
// clicking the canvas first to give it focus.
|
||||
|
||||
const EDITABLE_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel",
|
||||
]);
|
||||
|
||||
const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
if (target.disabled || target.readOnly) return false;
|
||||
const type = target.type?.toLowerCase() ?? "";
|
||||
return type === "" || EDITABLE_INPUT_TYPES.has(type);
|
||||
}
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
return !(target.disabled || target.readOnly);
|
||||
}
|
||||
return target.isContentEditable;
|
||||
};
|
||||
|
||||
export const useFreeCam = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
editor: Editor | null,
|
||||
) => {
|
||||
const stateRef = useRef<FreeCamControlState>(createFreeCamControlState());
|
||||
const dragRef = useRef<{ x: number; y: number; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Hand the state to the editor so its render loop can integrate it.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.cameraController.setFreeCamState(stateRef.current);
|
||||
return () => editor.cameraController.setFreeCamState(null);
|
||||
}, [editor]);
|
||||
|
||||
// Attach listeners. Pointer/wheel on canvas (so they only fire
|
||||
// inside the viewport); keys on document so the user doesn't need
|
||||
// to click canvas first for WASD to work.
|
||||
useEffect(() => {
|
||||
if (!canvas || !editor) return;
|
||||
const state = stateRef.current;
|
||||
state.enabled = true;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (usePageSceneStore.getState().isPromptBoxFocused) return;
|
||||
if (isEventFromEditableElement(e)) return;
|
||||
const moveSlot = moveSlotForKeyCode(e.code);
|
||||
if (moveSlot) state.moveKeys[moveSlot] = 1;
|
||||
const rotateSlot = rotateSlotForKeyCode(e.code);
|
||||
if (rotateSlot) state.rotateKeys[rotateSlot] = 1;
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (usePageSceneStore.getState().isPromptBoxFocused) return;
|
||||
if (isEventFromEditableElement(e)) return;
|
||||
const moveSlot = moveSlotForKeyCode(e.code);
|
||||
if (moveSlot) state.moveKeys[moveSlot] = 0;
|
||||
const rotateSlot = rotateSlotForKeyCode(e.code);
|
||||
if (rotateSlot) state.rotateKeys[rotateSlot] = 0;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.button !== 2) return;
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
|
||||
state.velocity.set(0, 0, 0);
|
||||
try {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// pointerCapture can throw if pointer is already captured elsewhere
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!dragRef.current || dragRef.current.pointerId !== e.pointerId) return;
|
||||
try {
|
||||
canvas.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore: capture may already be released
|
||||
}
|
||||
dragRef.current = null;
|
||||
state.velocity.set(0, 0, 0);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
const camera = editor.cameraController.camera;
|
||||
if (!drag || !camera) return;
|
||||
const dx = e.clientX - drag.x;
|
||||
const dy = e.clientY - drag.y;
|
||||
drag.x = e.clientX;
|
||||
drag.y = e.clientY;
|
||||
if (Math.abs(dx) + Math.abs(dy) === 0) return;
|
||||
|
||||
const pan = panFromDrag(dx, dy, state.movementSpeed);
|
||||
state.velocity.x = MathUtils.lerp(state.velocity.x, pan.x, state.smoothing);
|
||||
state.velocity.y = MathUtils.lerp(state.velocity.y, pan.y, state.smoothing);
|
||||
camera.translateX(state.velocity.x);
|
||||
camera.translateY(state.velocity.y);
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const camera = editor.cameraController.camera;
|
||||
if (!camera) return;
|
||||
const z = zoomFromWheel(e.deltaY);
|
||||
state.velocity.z = MathUtils.lerp(state.velocity.z, z, state.smoothing);
|
||||
camera.translateZ(state.velocity.z);
|
||||
};
|
||||
|
||||
const onContextMenu = (e: Event) => e.preventDefault();
|
||||
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
canvas.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: true });
|
||||
canvas.addEventListener("contextmenu", onContextMenu);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
|
||||
return () => {
|
||||
state.enabled = false;
|
||||
state.moveKeys = emptyMoveKeys();
|
||||
state.rotateKeys = emptyRotateKeys();
|
||||
state.velocity.set(0, 0, 0);
|
||||
dragRef.current = null;
|
||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||
canvas.removeEventListener("pointerup", onPointerUp);
|
||||
canvas.removeEventListener("pointermove", onPointerMove);
|
||||
canvas.removeEventListener("wheel", onWheel);
|
||||
canvas.removeEventListener("contextmenu", onContextMenu);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, [canvas, editor]);
|
||||
};
|
||||
export { useFreeCam } from "@storyteller/ui-pagescene";
|
||||
@@ -1,58 +1 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type Editor from "~/pages/PageScene/engine/editor";
|
||||
import { buildKeymap, dispatchBinding } from "~/pages/PageScene/engine/keymap";
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
|
||||
// Editable inputs we *don't* want shortcut keys to fire from. Mirrors
|
||||
// the original MouseControls.isEventFromEditableElement guard.
|
||||
const EDITABLE_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel",
|
||||
]);
|
||||
|
||||
const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
if (target.disabled || target.readOnly) return false;
|
||||
const type = target.type?.toLowerCase() ?? "";
|
||||
return type === "" || EDITABLE_INPUT_TYPES.has(type);
|
||||
}
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
return !(target.disabled || target.readOnly);
|
||||
}
|
||||
return target.isContentEditable;
|
||||
};
|
||||
|
||||
// Canvas-scoped keyboard shortcut handling. Reads the declarative
|
||||
// keymap and dispatches against incoming events. Listening to
|
||||
// `keydown` on `document` rather than on the canvas itself because:
|
||||
// (a) canvas focus is finicky — users shouldn't have to click the
|
||||
// viewport before T toggles transform mode, and
|
||||
// (b) the editable-element guard already prevents key handling while
|
||||
// any input/textarea has focus.
|
||||
//
|
||||
// Held movement keys (W/A/S/D etc.) are *not* in this keymap; they're
|
||||
// owned by useFreeCam since they're continuous motion, not one-shots.
|
||||
|
||||
export const useViewportKeyboard = (editor: Editor | null) => {
|
||||
const bindings = useMemo(() => buildKeymap(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEventFromEditableElement(event)) return;
|
||||
if (usePageSceneStore.getState().hotkeyStatus.disabled) return;
|
||||
dispatchBinding(bindings, event, editor);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [editor, bindings]);
|
||||
};
|
||||
export { useViewportKeyboard } from "@storyteller/ui-pagescene";
|
||||
@@ -1,66 +1 @@
|
||||
import { useEffect } from "react";
|
||||
import type Editor from "~/pages/PageScene/engine/editor";
|
||||
import { applyNdcToVector2 } from "~/pages/PageScene/engine/pointer";
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
import { EditorStates } from "~/pages/PageScene/enums";
|
||||
|
||||
// Canvas-scoped pointer handling for the 3D viewport. Replaces the
|
||||
// window-level mousemove / click / mousedown / mouseup listeners that
|
||||
// SceneManager used to attach in dev mode.
|
||||
//
|
||||
// Behaviour preserved verbatim from MouseControls (selection raycast,
|
||||
// gizmo drag, FK joint pick) — this hook is the lifecycle owner; the
|
||||
// MouseControls class still implements the interaction state machine.
|
||||
// A follow-up PR can dissolve MouseControls further; for now the goal
|
||||
// is "no window listeners" + "React owns attachment".
|
||||
|
||||
export const useViewportPointer = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
editor: Editor | null,
|
||||
) => {
|
||||
// While the camera-view free-cam owns drag state, selection / FK
|
||||
// pointer handling stays out of the way.
|
||||
const cameraViewActive = usePageSceneStore(
|
||||
(s) => s.editorState === EditorStates.CAMERA_VIEW,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas || !editor) return;
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!editor.mouse) return;
|
||||
applyNdcToVector2(
|
||||
editor.mouse,
|
||||
canvas.getBoundingClientRect(),
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
editor.mouse_controls?.onMouseMove(e as unknown as MouseEvent);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
editor.mouse_controls?.onMouseDown(e);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
editor.mouse_controls?.onMouseUp(e);
|
||||
};
|
||||
|
||||
const onClick = (_e: MouseEvent) => {
|
||||
if (cameraViewActive) return;
|
||||
editor.mouse_controls?.onMouseClick();
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
canvas.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("click", onClick);
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener("pointermove", onPointerMove);
|
||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||
canvas.removeEventListener("pointerup", onPointerUp);
|
||||
canvas.removeEventListener("click", onClick);
|
||||
};
|
||||
}, [canvas, editor, cameraViewActive]);
|
||||
};
|
||||
export { useViewportPointer } from "@storyteller/ui-pagescene";
|
||||
@@ -1,18 +1 @@
|
||||
// Aggregates "meta" parameters from the live editor + the Zustand store
|
||||
// for the save-scene flow. This lives outside engine/ because it reads
|
||||
// from the store directly — engine code stays store-agnostic.
|
||||
|
||||
import { SceneGenereationMetaData as SceneGenerationMetaData } from "./models/sceneGenerationMetadata";
|
||||
import Editor from "./engine/editor";
|
||||
import { usePageSceneStore } from "./PageSceneStore";
|
||||
|
||||
export const getSceneGenerationMetaData = (
|
||||
editorEngine: Editor,
|
||||
): SceneGenerationMetaData => {
|
||||
// when this is called, editor engine is guaranteed by its caller
|
||||
const s = usePageSceneStore.getState();
|
||||
return {
|
||||
positivePrompt: editorEngine.positive_prompt,
|
||||
cameraAspectRatio: s.cameraAspectRatio,
|
||||
};
|
||||
};
|
||||
export { getSceneGenerationMetaData } from "@storyteller/ui-pagescene";
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
} from "@storyteller/api";
|
||||
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
|
||||
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
|
||||
import { authentication, signalScene } from "~/signals";
|
||||
import {
|
||||
authentication,
|
||||
pageHeight,
|
||||
pageWidth,
|
||||
signalScene,
|
||||
} from "~/signals";
|
||||
|
||||
const apiHost = () =>
|
||||
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
@@ -234,6 +239,11 @@ export const useTauriPageSceneAdapter = (
|
||||
|
||||
onSceneTitleChange: (meta) => signalScene(meta as never),
|
||||
|
||||
getViewportSize: () => ({
|
||||
width: pageWidth.value,
|
||||
height: pageHeight.value,
|
||||
}),
|
||||
|
||||
// Slot renderers — host-owned UI for asset browser + scene
|
||||
// loader. Today these are still rendered inside PageEditor's own
|
||||
// asset modal; once those move into the lib, the lib will call
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
|
||||
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
|
||||
@@ -82,6 +82,47 @@ export { LockAction } from "./lib/engine/editor/actions/LockAction";
|
||||
export { TransformAction } from "./lib/engine/editor/actions/TransformAction";
|
||||
export { VisibilityAction } from "./lib/engine/editor/actions/VisibilityAction";
|
||||
|
||||
// Action dispatchers — host UI calls these to manipulate the scene
|
||||
// (drop assets, transform objects, color picks, etc.).
|
||||
export {
|
||||
addCharacter,
|
||||
addObject,
|
||||
addShape,
|
||||
deleteObject,
|
||||
selectObject,
|
||||
deselectObject,
|
||||
setCameraAspect,
|
||||
setObjectColor,
|
||||
beginColorSession,
|
||||
setTransformMode,
|
||||
toggleObjectLock,
|
||||
toggleObjectVisibility,
|
||||
beginTransformSession,
|
||||
} from "./lib/actions";
|
||||
export type { ColorSession, TransformSession } from "./lib/actions";
|
||||
|
||||
// Hooks — engine + DOM glue used by the lib's React components and
|
||||
// any host that mounts custom UI inside the editor.
|
||||
export { useEditorCanvas, useCamViewCanvas } from "./lib/hooks/useEditorCanvas";
|
||||
export { useFreeCam } from "./lib/hooks/useFreeCam";
|
||||
export { useViewportPointer } from "./lib/hooks/useViewportPointer";
|
||||
export { useViewportKeyboard } from "./lib/hooks/useViewportKeyboard";
|
||||
|
||||
// Engine context — React tree access to the live Editor instance.
|
||||
export {
|
||||
EngineContext,
|
||||
setActiveEditor,
|
||||
getActiveEditor,
|
||||
} from "./lib/contexts/EngineContext/EngineContext";
|
||||
export type { EditorExpandedI } from "./lib/contexts/EngineContext/EngineContext";
|
||||
export { EngineProvider } from "./lib/contexts/EngineContext/EngineProvider";
|
||||
|
||||
// Drag-and-drop singleton (used by AssetMenu items + GalleryDragComponent).
|
||||
export { default as dragAndDrop } from "./lib/DragAndDrop/DndAsset";
|
||||
|
||||
// Scene-generation metadata helper (used by save flow + cache snapshot).
|
||||
export { getSceneGenerationMetaData } from "./lib/sceneMetadata";
|
||||
|
||||
// Engine utilities exported for host wrappers + hooks.
|
||||
export { pickDropPosition } from "./lib/engine/pickDropPosition";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from "react";
|
||||
import { MediaItem } from "../models";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import { AssetType } from "../enums";
|
||||
import type Editor from "../engine/editor";
|
||||
import { pickDropPosition } from "../engine/pickDropPosition";
|
||||
import {
|
||||
addCharacter,
|
||||
addObject,
|
||||
addShape,
|
||||
} from "../actions";
|
||||
|
||||
// Top-of-page Y exclusion for the host's TopBar — drops above this
|
||||
// pixel cutoff are rejected. Stable for the artcraft layout; if a
|
||||
// future host needs a different value the dnd-asset would gain a
|
||||
// dropTop deps callback. Today it's a constant.
|
||||
const TOP_BAR_PX = 69;
|
||||
|
||||
class DndAsset {
|
||||
public dropId: string = "";
|
||||
public overElement: DOMRect | null = null;
|
||||
public dropOffset = 0;
|
||||
public initX = 0;
|
||||
public initY = 0;
|
||||
public notDropText = "";
|
||||
public isDragging: boolean = false;
|
||||
public dragThreshold: number = 5;
|
||||
private editor: Editor | null = null;
|
||||
|
||||
constructor() {
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
item: MediaItem,
|
||||
editor: Editor | null,
|
||||
) {
|
||||
if (event.button === 0) {
|
||||
this.editor = editor;
|
||||
const store = usePageSceneStore.getState();
|
||||
store.setDragItem(item);
|
||||
store.setDragPosition({ currX: event.pageX, currY: event.pageY });
|
||||
this.initX = event.pageX;
|
||||
this.initY = event.pageY;
|
||||
this.isDragging = false;
|
||||
store.setCanDrop(false);
|
||||
this.notDropText = "";
|
||||
store.setAssetModalVisibleDuringDrag(false);
|
||||
window.addEventListener("pointerup", this.onPointerUp);
|
||||
window.addEventListener("pointermove", this.onPointerMove);
|
||||
}
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
const store = usePageSceneStore.getState();
|
||||
if (store.dragItem) {
|
||||
store.setDragItem(null);
|
||||
store.setCanDrop(false);
|
||||
this.overElement = null;
|
||||
this.notDropText = "";
|
||||
store.setAssetModalVisibleDuringDrag(store.reopenAfterDrag);
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
overCanvas(positionX: number, positionY: number) {
|
||||
// Page dimensions come from the host adapter when supplied;
|
||||
// fall back to window so the lib remains usable in plain web
|
||||
// hosts that don't drive a `pageWidth`/`pageHeight` signal.
|
||||
const size = this.editor?.adapter.getViewportSize?.() ?? {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
if (positionY < TOP_BAR_PX) {
|
||||
return false;
|
||||
}
|
||||
if (positionY > size.height) {
|
||||
return false;
|
||||
}
|
||||
return positionX <= size.width;
|
||||
}
|
||||
|
||||
onPointerUp(event: PointerEvent) {
|
||||
window.removeEventListener("pointerup", this.onPointerUp);
|
||||
window.removeEventListener("pointermove", this.onPointerMove);
|
||||
|
||||
const store = usePageSceneStore.getState();
|
||||
if (!this.isDragging) {
|
||||
store.setAssetModalVisibleDuringDrag(true);
|
||||
store.setDragItem(null);
|
||||
store.setDragPosition({ currX: 0, currY: 0 });
|
||||
this.editor = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = this.editor;
|
||||
const mediaItem = store.dragItem;
|
||||
if (mediaItem && editor) {
|
||||
const positionX = event.pageX;
|
||||
const positionY = event.pageY;
|
||||
if (this.overCanvas(positionX, positionY)) {
|
||||
const worldPosition = pickDropPosition(
|
||||
{
|
||||
getCamera: () => editor.cameraController.camera,
|
||||
getCanvas: () => editor.renderer?.domElement,
|
||||
getRaycastTargets: () => editor.activeScene.scene.children,
|
||||
removeTransformControls: () =>
|
||||
editor.utils.removeTransformControls(true),
|
||||
},
|
||||
positionX,
|
||||
positionY,
|
||||
);
|
||||
if (mediaItem.type === AssetType.CHARACTER) {
|
||||
void addCharacter(editor, mediaItem, worldPosition);
|
||||
} else if (
|
||||
mediaItem.type === AssetType.OBJECT ||
|
||||
mediaItem.type === AssetType.SPLAT ||
|
||||
mediaItem.type === AssetType.SKYBOX
|
||||
) {
|
||||
void addObject(editor, mediaItem, worldPosition);
|
||||
} else if (mediaItem.type === AssetType.SHAPE) {
|
||||
void addShape(editor, mediaItem, worldPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
onPointerMove(event: MouseEvent) {
|
||||
const store = usePageSceneStore.getState();
|
||||
if (store.dragItem) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const deltaX = event.pageX - this.initX;
|
||||
const deltaY = event.pageY - this.initY;
|
||||
if (
|
||||
Math.abs(deltaX) > this.dragThreshold ||
|
||||
Math.abs(deltaY) > this.dragThreshold
|
||||
) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
store.setDragPosition({
|
||||
currX: this.initX + deltaX,
|
||||
currY: this.initY + deltaY,
|
||||
});
|
||||
if (this.overElement) {
|
||||
const pos = this.overElement;
|
||||
const eventY = event.pageY;
|
||||
const inHeight = eventY >= pos.top && eventY <= pos.top + pos.height;
|
||||
const eventX = event.pageX;
|
||||
const inWidth = eventX >= pos.left && eventX <= pos.left + pos.width;
|
||||
|
||||
if (inHeight && inWidth) {
|
||||
return;
|
||||
}
|
||||
store.setCanDrop(false);
|
||||
this.dropId = "";
|
||||
this.overElement = null;
|
||||
this.notDropText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dragAndDrop = new DndAsset();
|
||||
|
||||
export default dragAndDrop;
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as THREE from "three";
|
||||
import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export async function addCharacter(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
const obj = await editor.sceneManager?.create(
|
||||
item.media_id,
|
||||
item.name ?? "character",
|
||||
position ?? new THREE.Vector3(),
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
|
||||
obj.userData.isCharacter = true;
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: "character",
|
||||
name: obj.name || (item.name ?? "character"),
|
||||
mediaId: item.media_id,
|
||||
}),
|
||||
);
|
||||
editor.selection.refreshOutliner();
|
||||
return obj.uuid;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as THREE from "three";
|
||||
import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export async function addObject(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
const obj = await editor.sceneManager?.create(
|
||||
item.media_id,
|
||||
item.name ?? "object",
|
||||
position ?? new THREE.Vector3(),
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: "object",
|
||||
name: obj.name || (item.name ?? "object"),
|
||||
mediaId: item.media_id,
|
||||
}),
|
||||
);
|
||||
editor.selection.refreshOutliner();
|
||||
return obj.uuid;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as THREE from "three";
|
||||
import type Editor from "../engine/editor";
|
||||
import { MediaItem } from "../models";
|
||||
import { CreateAction } from "../engine/editor/actions/CreateAction";
|
||||
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export async function addShape(
|
||||
editor: Editor,
|
||||
item: MediaItem,
|
||||
position?: THREE.Vector3,
|
||||
): Promise<string | undefined> {
|
||||
const obj = await editor.sceneManager?.create(
|
||||
"Parim",
|
||||
item.media_id,
|
||||
position ?? new THREE.Vector3(),
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
// Stash the geometry key (Box / Sphere / PointLight / ...) so undo/redo
|
||||
// can re-route through scene.instantiate's name switch. obj.name gets
|
||||
// overridden below with the display label ("Cube", "Point Light").
|
||||
obj.userData.shapeKey = item.media_id;
|
||||
obj.name = item.name ?? "shape";
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: "shape",
|
||||
name: obj.name,
|
||||
mediaId: item.media_id,
|
||||
}),
|
||||
);
|
||||
editor.selection.refreshOutliner();
|
||||
return obj.uuid;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type Editor from "../engine/editor";
|
||||
|
||||
// Editor.deleteObject already removes the THREE.js object via SceneUtils,
|
||||
// which emits ObjectRemovedEvent + InspectorPanelChangedEvent on the
|
||||
// engine bus; the bridge translates those into the matching store
|
||||
// mutations. This action exists for symmetry with addObject and to
|
||||
// give callers a single import surface.
|
||||
export function deleteObject(editor: Editor, id: string): void {
|
||||
editor.deleteObject(id);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from "./addObject";
|
||||
export * from "./addCharacter";
|
||||
export * from "./addShape";
|
||||
export * from "./deleteObject";
|
||||
export * from "./selectObject";
|
||||
export * from "./setTransformMode";
|
||||
export * from "./setCameraAspect";
|
||||
export * from "./setObjectColor";
|
||||
export * from "./toggleObjectLock";
|
||||
export * from "./toggleObjectVisibility";
|
||||
export * from "./transformObject";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type Editor from "../engine/editor";
|
||||
|
||||
// Selecting in the engine triggers Editor.publishSelect which writes the
|
||||
// selection into the store, so this action only needs to invoke the engine
|
||||
// path.
|
||||
export function selectObject(editor: Editor, id: string): void {
|
||||
editor.sceneManager?.select_object(id);
|
||||
}
|
||||
|
||||
export function deselectObject(editor: Editor): void {
|
||||
if (editor.sceneManager) {
|
||||
editor.sceneManager.selected_objects = undefined;
|
||||
}
|
||||
editor.selection.publishSelect();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { CameraAspectRatio } from "../enums";
|
||||
import { CameraAspectRatioChangedEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
export function setCameraAspect(
|
||||
editor: Editor,
|
||||
ratio: CameraAspectRatio,
|
||||
): void {
|
||||
editor.cameraController.changeRenderCameraAspectRatio(ratio);
|
||||
editor.bus.emit(new CameraAspectRatioChangedEvent(ratio));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { ColorAction } from "../engine/editor/actions/ColorAction";
|
||||
|
||||
// One-shot apply: use when the change is atomic (e.g. a button click
|
||||
// or programmatic setter). Records a single ColorAction.
|
||||
//
|
||||
// For interactive flows where a single user gesture fires many
|
||||
// intermediate values (the native <input type="color"> dialog spams
|
||||
// onChange per slider pixel), use beginColorSession instead — it
|
||||
// applies each intermediate value to the engine for visual feedback
|
||||
// but records only ONE ColorAction on commit.
|
||||
export function setObjectColor(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
color: string,
|
||||
): void {
|
||||
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (!obj) return;
|
||||
const before = (obj.userData.color as string) ?? "#ffffff";
|
||||
if (before === color) return;
|
||||
editor.activeScene.setColor(uuid, color);
|
||||
editor.history.record(new ColorAction(editor, uuid, before, color));
|
||||
}
|
||||
|
||||
// Session-style color edit: snapshots the before-state at construction
|
||||
// (focus / picker open), applies each intermediate value to the engine
|
||||
// (visual feedback during slider drag), and records exactly one
|
||||
// ColorAction on commit (focus loss / picker close). No-op on commit
|
||||
// if the final color matches the before-state.
|
||||
export type ColorSession = {
|
||||
apply(color: string): void;
|
||||
commit(): void;
|
||||
};
|
||||
|
||||
export function beginColorSession(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
): ColorSession {
|
||||
const startObj = editor.activeScene.scene.getObjectByProperty(
|
||||
"uuid",
|
||||
uuid,
|
||||
);
|
||||
const before = (startObj?.userData.color as string) ?? "#ffffff";
|
||||
return {
|
||||
apply(color: string) {
|
||||
editor.activeScene.setColor(uuid, color);
|
||||
},
|
||||
commit() {
|
||||
const endObj = editor.activeScene.scene.getObjectByProperty(
|
||||
"uuid",
|
||||
uuid,
|
||||
);
|
||||
const after = (endObj?.userData.color as string) ?? before;
|
||||
if (before === after) return;
|
||||
editor.history.record(new ColorAction(editor, uuid, before, after));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import type { TransformMode } from "../PageSceneStore";
|
||||
import {
|
||||
SelectedModeChangedEvent,
|
||||
TransformModeChangedEvent,
|
||||
} from "../engine/events/EngineEvent";
|
||||
|
||||
const ENGINE_MODE: Record<TransformMode, "translate" | "rotate" | "scale"> = {
|
||||
move: "translate",
|
||||
rotate: "rotate",
|
||||
scale: "scale",
|
||||
};
|
||||
|
||||
export function setTransformMode(editor: Editor, mode: TransformMode): void {
|
||||
editor.gizmo.changeMode(ENGINE_MODE[mode]);
|
||||
editor.bus.emit(new TransformModeChangedEvent(mode));
|
||||
editor.bus.emit(new SelectedModeChangedEvent(mode));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { LockAction } from "../engine/editor/actions/LockAction";
|
||||
import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
// Toggle the locked state of an object: flips userData.locked, runs
|
||||
// the gizmo attach/detach side effect, syncs the outliner row icon in
|
||||
// the Zustand store, and records the toggle on the undo stack.
|
||||
//
|
||||
// View callers (Outliner) only need to provide editor + uuid; the
|
||||
// action handles the engine + store + history orchestration.
|
||||
export function toggleObjectLock(editor: Editor, uuid: string): void {
|
||||
const before = editor.selection.isObjectLocked(uuid);
|
||||
editor.bus.emit(new OutlinerItemLockToggledEvent(uuid));
|
||||
editor.selection.lockUnlockObject(uuid);
|
||||
editor.history.record(
|
||||
new LockAction(editor.selection, uuid, before, !before),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { VisibilityAction } from "../engine/editor/actions/VisibilityAction";
|
||||
import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent";
|
||||
|
||||
// Toggle the visibility of an object: flips obj.visible +
|
||||
// userData.visible, syncs the outliner row icon in the Zustand store,
|
||||
// and records the toggle on the undo stack.
|
||||
//
|
||||
// View callers (Outliner) only need to provide editor + uuid; the
|
||||
// helper reads the before-state from the live engine object so the
|
||||
// view doesn't have to plumb it.
|
||||
export function toggleObjectVisibility(editor: Editor, uuid: string): void {
|
||||
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
|
||||
if (!obj) return;
|
||||
const before = obj.visible;
|
||||
editor.bus.emit(new OutlinerItemVisibilityToggledEvent(uuid));
|
||||
editor.sceneManager?.hideObject(uuid);
|
||||
editor.history.record(
|
||||
new VisibilityAction(editor, uuid, before, !before),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type Editor from "../engine/editor";
|
||||
import { TransformAction } from "../engine/editor/actions/TransformAction";
|
||||
|
||||
// A transform "session" — opened on the first edit of a multi-input
|
||||
// editing flow (e.g. Object Panel position/rotation/scale text fields),
|
||||
// committed at the end of the session (selection change, panel
|
||||
// unmount, focus moving away from the panel).
|
||||
//
|
||||
// The session captures the before-state at construction. On commit it
|
||||
// captures the after-state, pushes a single TransformAction onto the
|
||||
// undo stack only if the transform actually changed, and re-syncs the
|
||||
// Zustand `objectPanel` slice from the engine so the panel's view of
|
||||
// the selection matches the engine state.
|
||||
//
|
||||
// Engine-level concerns (capture / apply / revert) stay in
|
||||
// `engine/editor/actions/TransformAction.ts`; this file is the
|
||||
// orchestration wrapper that the action layer (UI code) uses so the
|
||||
// view never has to construct an UndoableAction directly.
|
||||
export type TransformSession = {
|
||||
commit(): void;
|
||||
};
|
||||
|
||||
export function beginTransformSession(
|
||||
editor: Editor,
|
||||
uuid: string,
|
||||
): TransformSession {
|
||||
const action = new TransformAction(editor, uuid);
|
||||
return {
|
||||
commit() {
|
||||
if (action.commit()) {
|
||||
editor.history.record(action);
|
||||
}
|
||||
// Push the engine's post-edit transform back into the Zustand
|
||||
// objectPanel slice so the panel's currentSceneObject vectors
|
||||
// are in sync with what the user just committed. Idempotent.
|
||||
editor.selection.updateSelectedUI();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,13 @@ export interface PageSceneAdapter {
|
||||
// when they only have the token, not the full URL.
|
||||
getMediaUrlByToken(token: string): Promise<string>;
|
||||
|
||||
// Visual viewport dimensions. Used by DnD asset-drop hit-testing
|
||||
// to detect "is this drop over the 3D canvas?" — the artcraft host
|
||||
// wires its `pageWidth`/`pageHeight` signals through; web hosts
|
||||
// without those signals can omit, and the lib falls back to
|
||||
// window.innerWidth/innerHeight.
|
||||
getViewportSize?(): { width: number; height: number };
|
||||
|
||||
// Slot renders for host-owned UI. The library renders these inside
|
||||
// its own AssetMenu / scene-load modal containers — same shape as
|
||||
// PageDrawAdapter.renderBaseImageSelector.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext } from "react";
|
||||
import Editor from "../../engine/editor";
|
||||
|
||||
export interface EditorExpandedI extends Editor {
|
||||
setSceneContainer: React.Dispatch<
|
||||
React.SetStateAction<HTMLDivElement | null>
|
||||
>;
|
||||
setEditorCanvas: React.Dispatch<
|
||||
React.SetStateAction<HTMLCanvasElement | null>
|
||||
>;
|
||||
setCamViewCanvas: React.Dispatch<
|
||||
React.SetStateAction<HTMLCanvasElement | null>
|
||||
>;
|
||||
}
|
||||
|
||||
export const EngineContext = createContext<Editor | null>(null);
|
||||
|
||||
// Module-level handle on the active Editor instance. EngineProvider keeps
|
||||
// this in sync with its lifecycle. React components should still use
|
||||
// useContext(EngineContext) — this exists for non-React callers (and for
|
||||
// cross-page actions that fire before the 3D tab has mounted).
|
||||
let activeEditor: Editor | null = null;
|
||||
export const getActiveEditor = (): Editor | null => activeEditor;
|
||||
export const setActiveEditor = (editor: Editor | null): void => {
|
||||
activeEditor = editor;
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { EngineContext, setActiveEditor } from "./EngineContext";
|
||||
|
||||
import Editor from "../../engine/editor";
|
||||
import type { PageSceneAdapter } from "../../adapter";
|
||||
import { getSceneGenerationMetaData } from "../../sceneMetadata";
|
||||
import { usePageSceneStore } from "../../PageSceneStore";
|
||||
|
||||
interface Props {
|
||||
sceneToken?: string;
|
||||
// The platform-abstraction surface the lib's Editor needs. Built by
|
||||
// the artcraft wrapper from Tauri APIs (FetchProxy, GenerateImage,
|
||||
// MediaFilesApi, useTabStore for tab cache, etc.) and threaded down.
|
||||
adapter: PageSceneAdapter;
|
||||
// In-memory restore: if provided, the engine deserializes this on
|
||||
// mount instead of fetching the scene by token. Host-supplied (e.g.
|
||||
// artcraft sources it from useTabStore so tab switches preserve the
|
||||
// scene without an HTTP roundtrip).
|
||||
cacheJsonString?: string;
|
||||
// In-memory save: called on unmount with the serialized scene JSON.
|
||||
// The host stashes it wherever it likes (tabStore, localStorage,
|
||||
// nowhere) — the lib stays storage-agnostic.
|
||||
onSceneSerialized?: (json: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Drives the Editor lifecycle from React mount/unmount + the
|
||||
// availability of the canvas DOM nodes. No tab knowledge — the host
|
||||
// decides when this provider is mounted (e.g. only when the 3D tab is
|
||||
// active). When the canvases unmount, callback refs nulled in
|
||||
// PageSceneStore drive the cleanup branch of this effect.
|
||||
export const EngineProvider = ({
|
||||
sceneToken,
|
||||
adapter,
|
||||
cacheJsonString,
|
||||
onSceneSerialized,
|
||||
children,
|
||||
}: Props) => {
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
// Hold the latest cache + serialize callback in refs so the engine
|
||||
// lifecycle effect can read them in cleanup without re-running on
|
||||
// every prop change. (The editor itself doesn't need a mirror ref —
|
||||
// closure capture of the local `newEditor` handles cleanup.)
|
||||
const cacheRef = useRef(cacheJsonString);
|
||||
cacheRef.current = cacheJsonString;
|
||||
const onSerializeRef = useRef(onSceneSerialized);
|
||||
onSerializeRef.current = onSceneSerialized;
|
||||
// Adapter is read at engine-construction time only; ref guards
|
||||
// against host-side identity changes triggering unwanted rebuilds.
|
||||
const adapterRef = useRef(adapter);
|
||||
adapterRef.current = adapter;
|
||||
|
||||
const sceneContainer = usePageSceneStore((s) => s.sceneContainerEl);
|
||||
const editorCanvas = usePageSceneStore((s) => s.editorCanvasEl);
|
||||
const camViewCanvas = usePageSceneStore((s) => s.camViewCanvasEl);
|
||||
|
||||
useEffect(() => {
|
||||
// Engine construction happens once all three DOM nodes are
|
||||
// available; callback refs in SceneContainer / EditorCanvas /
|
||||
// CameraViewCanvas drive this by setting their nodes (and clearing
|
||||
// them to null on unmount).
|
||||
if (!sceneContainer || !editorCanvas || !camViewCanvas) return;
|
||||
|
||||
const newEditor = new Editor(adapterRef.current);
|
||||
newEditor.initialize({
|
||||
sceneToken: sceneToken || "",
|
||||
sceneContainerEl: sceneContainer,
|
||||
editorCanvasEl: editorCanvas,
|
||||
camViewCanvasEl: camViewCanvas,
|
||||
cacheJsonString: cacheRef.current,
|
||||
});
|
||||
setEditor(newEditor);
|
||||
setActiveEditor(newEditor);
|
||||
|
||||
return () => {
|
||||
// Snapshot scene to host-managed cache so we can restore it on
|
||||
// remount. Skip if the scene never finished loading; the host's
|
||||
// last-known-good cache is preserved on its side.
|
||||
if (newEditor.isEngineDataLoaded()) {
|
||||
const sceneGenerationMetadata = getSceneGenerationMetaData(newEditor);
|
||||
const cacheJson = newEditor.save_manager.getSceneJson({
|
||||
sceneGenerationMetadata,
|
||||
});
|
||||
onSerializeRef.current?.(JSON.stringify(cacheJson));
|
||||
}
|
||||
|
||||
newEditor.unmountEngine();
|
||||
setEditor(null);
|
||||
setActiveEditor(null);
|
||||
};
|
||||
}, [sceneToken, sceneContainer, editorCanvas, camViewCanvas]);
|
||||
|
||||
return (
|
||||
<EngineContext.Provider value={editor}>{children}</EngineContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./EngineContext";
|
||||
export * from "./EngineProvider";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
|
||||
// React-shaped accessors for the canvas DOM nodes that the engine
|
||||
// renders into. The underlying storage is the Zustand store (set by
|
||||
// EngineCanvases on mount); the selector triggers a re-render whenever
|
||||
// the canvas mounts/unmounts.
|
||||
|
||||
export function useEditorCanvas(): HTMLCanvasElement | null {
|
||||
return usePageSceneStore((s) => s.editorCanvasEl);
|
||||
}
|
||||
|
||||
export function useCamViewCanvas(): HTMLCanvasElement | null {
|
||||
return usePageSceneStore((s) => s.camViewCanvasEl);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MathUtils } from "three";
|
||||
import {
|
||||
createFreeCamControlState,
|
||||
emptyMoveKeys,
|
||||
emptyRotateKeys,
|
||||
moveSlotForKeyCode,
|
||||
panFromDrag,
|
||||
rotateSlotForKeyCode,
|
||||
zoomFromWheel,
|
||||
type FreeCamControlState,
|
||||
} from "../engine/cameraMath";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import type Editor from "../engine/editor";
|
||||
|
||||
// Drives the 3D viewport's "fly" camera controls (right-click pan,
|
||||
// wheel zoom, WASD/QE forward/back/strafe/up/down, arrow-key roll/yaw)
|
||||
// for navigating the scene in EDIT mode. CAMERA_VIEW (the virtual-
|
||||
// camera POV preview) re-uses the same input pipeline.
|
||||
//
|
||||
// Pointer + wheel listeners go on the canvas (events naturally route
|
||||
// to canvas regardless of focus). WASD/QE/arrow keys go on document
|
||||
// with an editable-element guard, so the user can fly without
|
||||
// clicking the canvas first to give it focus.
|
||||
|
||||
const EDITABLE_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel",
|
||||
]);
|
||||
|
||||
const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
if (target.disabled || target.readOnly) return false;
|
||||
const type = target.type?.toLowerCase() ?? "";
|
||||
return type === "" || EDITABLE_INPUT_TYPES.has(type);
|
||||
}
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
return !(target.disabled || target.readOnly);
|
||||
}
|
||||
return target.isContentEditable;
|
||||
};
|
||||
|
||||
export const useFreeCam = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
editor: Editor | null,
|
||||
) => {
|
||||
const stateRef = useRef<FreeCamControlState>(createFreeCamControlState());
|
||||
const dragRef = useRef<{ x: number; y: number; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Hand the state to the editor so its render loop can integrate it.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.cameraController.setFreeCamState(stateRef.current);
|
||||
return () => editor.cameraController.setFreeCamState(null);
|
||||
}, [editor]);
|
||||
|
||||
// Attach listeners. Pointer/wheel on canvas (so they only fire
|
||||
// inside the viewport); keys on document so the user doesn't need
|
||||
// to click canvas first for WASD to work.
|
||||
useEffect(() => {
|
||||
if (!canvas || !editor) return;
|
||||
const state = stateRef.current;
|
||||
state.enabled = true;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (usePageSceneStore.getState().isPromptBoxFocused) return;
|
||||
if (isEventFromEditableElement(e)) return;
|
||||
const moveSlot = moveSlotForKeyCode(e.code);
|
||||
if (moveSlot) state.moveKeys[moveSlot] = 1;
|
||||
const rotateSlot = rotateSlotForKeyCode(e.code);
|
||||
if (rotateSlot) state.rotateKeys[rotateSlot] = 1;
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (usePageSceneStore.getState().isPromptBoxFocused) return;
|
||||
if (isEventFromEditableElement(e)) return;
|
||||
const moveSlot = moveSlotForKeyCode(e.code);
|
||||
if (moveSlot) state.moveKeys[moveSlot] = 0;
|
||||
const rotateSlot = rotateSlotForKeyCode(e.code);
|
||||
if (rotateSlot) state.rotateKeys[rotateSlot] = 0;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.button !== 2) return;
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
|
||||
state.velocity.set(0, 0, 0);
|
||||
try {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// pointerCapture can throw if pointer is already captured elsewhere
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!dragRef.current || dragRef.current.pointerId !== e.pointerId) return;
|
||||
try {
|
||||
canvas.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore: capture may already be released
|
||||
}
|
||||
dragRef.current = null;
|
||||
state.velocity.set(0, 0, 0);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
const camera = editor.cameraController.camera;
|
||||
if (!drag || !camera) return;
|
||||
const dx = e.clientX - drag.x;
|
||||
const dy = e.clientY - drag.y;
|
||||
drag.x = e.clientX;
|
||||
drag.y = e.clientY;
|
||||
if (Math.abs(dx) + Math.abs(dy) === 0) return;
|
||||
|
||||
const pan = panFromDrag(dx, dy, state.movementSpeed);
|
||||
state.velocity.x = MathUtils.lerp(state.velocity.x, pan.x, state.smoothing);
|
||||
state.velocity.y = MathUtils.lerp(state.velocity.y, pan.y, state.smoothing);
|
||||
camera.translateX(state.velocity.x);
|
||||
camera.translateY(state.velocity.y);
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const camera = editor.cameraController.camera;
|
||||
if (!camera) return;
|
||||
const z = zoomFromWheel(e.deltaY);
|
||||
state.velocity.z = MathUtils.lerp(state.velocity.z, z, state.smoothing);
|
||||
camera.translateZ(state.velocity.z);
|
||||
};
|
||||
|
||||
const onContextMenu = (e: Event) => e.preventDefault();
|
||||
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
canvas.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: true });
|
||||
canvas.addEventListener("contextmenu", onContextMenu);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
|
||||
return () => {
|
||||
state.enabled = false;
|
||||
state.moveKeys = emptyMoveKeys();
|
||||
state.rotateKeys = emptyRotateKeys();
|
||||
state.velocity.set(0, 0, 0);
|
||||
dragRef.current = null;
|
||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||
canvas.removeEventListener("pointerup", onPointerUp);
|
||||
canvas.removeEventListener("pointermove", onPointerMove);
|
||||
canvas.removeEventListener("wheel", onWheel);
|
||||
canvas.removeEventListener("contextmenu", onContextMenu);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, [canvas, editor]);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type Editor from "../engine/editor";
|
||||
import { buildKeymap, dispatchBinding } from "../engine/keymap";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
|
||||
// Editable inputs we *don't* want shortcut keys to fire from. Mirrors
|
||||
// the original MouseControls.isEventFromEditableElement guard.
|
||||
const EDITABLE_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel",
|
||||
]);
|
||||
|
||||
const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
if (target.disabled || target.readOnly) return false;
|
||||
const type = target.type?.toLowerCase() ?? "";
|
||||
return type === "" || EDITABLE_INPUT_TYPES.has(type);
|
||||
}
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
return !(target.disabled || target.readOnly);
|
||||
}
|
||||
return target.isContentEditable;
|
||||
};
|
||||
|
||||
// Canvas-scoped keyboard shortcut handling. Reads the declarative
|
||||
// keymap and dispatches against incoming events. Listening to
|
||||
// `keydown` on `document` rather than on the canvas itself because:
|
||||
// (a) canvas focus is finicky — users shouldn't have to click the
|
||||
// viewport before T toggles transform mode, and
|
||||
// (b) the editable-element guard already prevents key handling while
|
||||
// any input/textarea has focus.
|
||||
//
|
||||
// Held movement keys (W/A/S/D etc.) are *not* in this keymap; they're
|
||||
// owned by useFreeCam since they're continuous motion, not one-shots.
|
||||
|
||||
export const useViewportKeyboard = (editor: Editor | null) => {
|
||||
const bindings = useMemo(() => buildKeymap(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEventFromEditableElement(event)) return;
|
||||
if (usePageSceneStore.getState().hotkeyStatus.disabled) return;
|
||||
dispatchBinding(bindings, event, editor);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [editor, bindings]);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import type Editor from "../engine/editor";
|
||||
import { applyNdcToVector2 } from "../engine/pointer";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import { EditorStates } from "../enums";
|
||||
|
||||
// Canvas-scoped pointer handling for the 3D viewport. Replaces the
|
||||
// window-level mousemove / click / mousedown / mouseup listeners that
|
||||
// SceneManager used to attach in dev mode.
|
||||
//
|
||||
// Behaviour preserved verbatim from MouseControls (selection raycast,
|
||||
// gizmo drag, FK joint pick) — this hook is the lifecycle owner; the
|
||||
// MouseControls class still implements the interaction state machine.
|
||||
// A follow-up PR can dissolve MouseControls further; for now the goal
|
||||
// is "no window listeners" + "React owns attachment".
|
||||
|
||||
export const useViewportPointer = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
editor: Editor | null,
|
||||
) => {
|
||||
// While the camera-view free-cam owns drag state, selection / FK
|
||||
// pointer handling stays out of the way.
|
||||
const cameraViewActive = usePageSceneStore(
|
||||
(s) => s.editorState === EditorStates.CAMERA_VIEW,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas || !editor) return;
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!editor.mouse) return;
|
||||
applyNdcToVector2(
|
||||
editor.mouse,
|
||||
canvas.getBoundingClientRect(),
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
editor.mouse_controls?.onMouseMove(e as unknown as MouseEvent);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
editor.mouse_controls?.onMouseDown(e);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
editor.mouse_controls?.onMouseUp(e);
|
||||
};
|
||||
|
||||
const onClick = (_e: MouseEvent) => {
|
||||
if (cameraViewActive) return;
|
||||
editor.mouse_controls?.onMouseClick();
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
canvas.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("click", onClick);
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener("pointermove", onPointerMove);
|
||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||
canvas.removeEventListener("pointerup", onPointerUp);
|
||||
canvas.removeEventListener("click", onClick);
|
||||
};
|
||||
}, [canvas, editor, cameraViewActive]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// Aggregates "meta" parameters from the live editor + the Zustand store
|
||||
// for the save-scene flow. This lives outside engine/ because it reads
|
||||
// from the store directly — engine code stays store-agnostic.
|
||||
|
||||
import { SceneGenereationMetaData as SceneGenerationMetaData } from "./models/sceneGenerationMetadata";
|
||||
import Editor from "./engine/editor";
|
||||
import { usePageSceneStore } from "./PageSceneStore";
|
||||
|
||||
export const getSceneGenerationMetaData = (
|
||||
editorEngine: Editor,
|
||||
): SceneGenerationMetaData => {
|
||||
// when this is called, editor engine is guaranteed by its caller
|
||||
const s = usePageSceneStore.getState();
|
||||
return {
|
||||
positivePrompt: editorEngine.positive_prompt,
|
||||
cameraAspectRatio: s.cameraAspectRatio,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user