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:
Tanish Manku committed 2026-05-08 14:38:55 -07:00
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";
@@ -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 @@
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
View File
@@ -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,
};
};