Skip to content

Commit

Permalink
Add keyboard handler to new camera component, default camera context
Browse files Browse the repository at this point in the history
adeira-source-id: 5a52f93ad2a40ff0fdea2fffaa3ffdfdc7995fa2
  • Loading branch information
itsdouges authored and triplex-bot committed Feb 22, 2025
1 parent c390a50 commit adb4341
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Copyright (c) 2022—present Michael Dougall. All rights reserved.
*
* This repository utilizes multiple licenses across different directories. To
* see this files license find the nearest LICENSE file up the source tree.
*/
// @vitest-environment jsdom
import { default as CameraControlsInstance } from "camera-controls";
import { useContext } from "react";
import { render } from "react-three-test";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CameraControlsContext, DefaultCameraContext } from "../context";
import { Camera } from "../index";

const defaultModifiers = {
left: CameraControlsInstance.ACTION.ROTATE,
middle: CameraControlsInstance.ACTION.DOLLY,
right: CameraControlsInstance.ACTION.TRUCK,
};

const setVisibility = (state: "hidden" | "visible") => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get() {
return state;
},
});
};

const setDocumentFocus = (hasFocus: boolean) => {
Object.defineProperty(document, "hasFocus", {
configurable: true,
value: () => hasFocus,
});
};

describe("camera", () => {
beforeEach(() => {
vi.useFakeTimers({ loopLimit: 10 });
setDocumentFocus(true);
});

it("should default to rest modifiers", async () => {
let controlsRef: CameraControlsInstance | null = null;
function HoistControls() {
const controls = useContext(CameraControlsContext);
// eslint-disable-next-line react-compiler/react-compiler
controlsRef = controls;
return null;
}

await render(
<Camera>
<HoistControls />
</Camera>,
);

expect(controlsRef).toEqual({
mouseButtons: defaultModifiers,
touches: {},
});
});

it("should unmount controls when userland", async () => {
let controlsRef: CameraControlsInstance | null = null;
function HoistControls() {
const controls = useContext(CameraControlsContext);
// eslint-disable-next-line react-compiler/react-compiler
controlsRef = controls;
return null;
}

await render(
<DefaultCameraContext.Provider value="default">
<Camera>
<HoistControls />
</Camera>
</DefaultCameraContext.Provider>,
);

expect(controlsRef).toEqual(null);
});

it("should should apply truck modifier when pressing shift", async () => {
let controlsRef: CameraControlsInstance | null = null;
function HoistControls() {
const controls = useContext(CameraControlsContext);
// eslint-disable-next-line react-compiler/react-compiler
controlsRef = controls;
return null;
}
const { act, fireDOMEvent } = await render(
<Camera>
<HoistControls />
</Camera>,
);

await act(() => fireDOMEvent.keyDown(window, { key: "Shift" }));

expect(controlsRef).toEqual({
mouseButtons: {
...defaultModifiers,
left: CameraControlsInstance.ACTION.TRUCK,
},
touches: {},
});
});

it("should reset modifiers when releasing shift", async () => {
let controlsRef: CameraControlsInstance | null = null;
function HoistControls() {
const controls = useContext(CameraControlsContext);
// eslint-disable-next-line react-compiler/react-compiler
controlsRef = controls;
return null;
}
const { act, fireDOMEvent } = await render(
<Camera>
<HoistControls />
</Camera>,
);
await act(() => fireDOMEvent.keyDown(window, { key: "Shift" }));

await act(() => {
setVisibility("hidden");
fireDOMEvent(window, new Event("visibilitychange"));
setVisibility("visible");
});

expect(controlsRef).toEqual({
mouseButtons: defaultModifiers,
touches: {},
});
});

it("should reset modifiers when document frame loses focus", async () => {
let controlsRef: CameraControlsInstance | null = null;
function HoistControls() {
const controls = useContext(CameraControlsContext);
// eslint-disable-next-line react-compiler/react-compiler
controlsRef = controls;
return null;
}
const { act, fireDOMEvent } = await render(
<Camera>
<HoistControls />
</Camera>,
);

await act(() => fireDOMEvent.keyDown(window, { key: "Shift" }));

await act(() => {
setDocumentFocus(false);
vi.runAllTimers();
});

expect(controlsRef).toEqual({
mouseButtons: defaultModifiers,
touches: {},
});
});
});
122 changes: 120 additions & 2 deletions packages/renderer/src/features/camera-new/camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,132 @@
* see this files license find the nearest LICENSE file up the source tree.
*/

import { type default as CameraControlsInstance } from "camera-controls";
import { useContext, useState, type ReactNode } from "react";
import { bindAll } from "bind-event-listener";
import { default as CameraControlsInstance } from "camera-controls";
import { useContext, useEffect, useState, type ReactNode } from "react";
import { CameraControls as CameraControlsComponent } from "triplex-drei";
import { ActiveCameraContext, CameraControlsContext } from "./context";

const mouseHotkeys = {
ctrl: {},
rest: {
left: CameraControlsInstance.ACTION.ROTATE,
middle: CameraControlsInstance.ACTION.DOLLY,
right: CameraControlsInstance.ACTION.TRUCK,
},
shift: {
left: CameraControlsInstance.ACTION.TRUCK,
},
} satisfies Record<string, Partial<CameraControlsInstance["mouseButtons"]>>;

const touchHotkeys = {
ctrl: {},
rest: {},
shift: {},
} satisfies Record<string, Partial<CameraControlsInstance["touches"]>>;

function apply<TKey extends string>(
a: Record<TKey, number>,
b: Partial<Record<TKey, number>>,
) {
const allB = b as Record<TKey, number>;
for (const key in b) {
a[key] = allB[key];
}
}

export function CameraControls({ children }: { children: ReactNode }) {
const camera = useContext(ActiveCameraContext);
const [ref, setRef] = useState<CameraControlsInstance | null>(null);
const [modifier, setModifier] = useState<"Rest" | "Shift" | "Control">(
"Rest",
);

useEffect(() => {
if (!ref) {
return;
}

switch (modifier) {
case "Control":
apply(ref.touches, touchHotkeys.ctrl);
apply(ref.mouseButtons, mouseHotkeys.ctrl);
break;

case "Shift":
apply(ref.touches, touchHotkeys.shift);
apply(ref.mouseButtons, mouseHotkeys.shift);
break;

default:
apply(ref.touches, touchHotkeys.rest);
apply(ref.mouseButtons, mouseHotkeys.rest);
break;
}
}, [modifier, ref]);

useEffect(() => {
let intervalId: number;

return bindAll(window, [
{
listener: (event) => {
if (event.key !== "Shift" && event.key !== "Control") {
setModifier("Rest");
return;
}

function beginPollingForDocumentFocusLoss() {
if (!document.hasFocus()) {
// The iframe document doesn't have focus right now so the event
// has originated from the parent document. We skip polling here
// and instead wait for the next keyup event to reset the modifier.
return;
}

window.clearInterval(intervalId);

intervalId = window.setInterval(() => {
if (!document.hasFocus()) {
window.clearInterval(intervalId);
setModifier("Rest");
}
}, 200);
}

switch (event.key) {
case "Shift":
case "Control":
beginPollingForDocumentFocusLoss();
setModifier(event.key);
break;
}
},
type: "keydown",
},
{
listener: (event) => {
switch (event.key) {
case "Shift":
case "Control":
window.clearInterval(intervalId);
setModifier("Rest");
break;
}
},
type: "keyup",
},
{
listener: () => {
if (document.visibilityState === "hidden") {
window.clearInterval(intervalId);
setModifier("Rest");
}
},
type: "visibilitychange",
},
]);
}, []);

return (
<CameraControlsContext.Provider value={ref}>
Expand Down
14 changes: 11 additions & 3 deletions packages/renderer/src/features/camera-new/cameras.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { useFrame, useThree } from "@react-three/fiber";
import { on } from "@triplex/bridge/client";
import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
Expand All @@ -17,8 +18,12 @@ import {
} from "react";
import { type OrthographicCamera, type PerspectiveCamera } from "three";
import { usePlayState } from "../../stores/use-play-state";
import { EDITOR_LAYER_INDEX } from "../../util/layers";
import { ActiveCameraContext, type ActiveCameraContextValue } from "./context";
import { EDITOR_LAYER_INDEX, HIDDEN_LAYER_INDEX } from "../../util/layers";
import {
ActiveCameraContext,
DefaultCameraContext,
type ActiveCameraContextValue,
} from "./context";
import { fitCamerasToViewport } from "./fit-cameras-to-viewport";
import { type CanvasCamera, type EditorCameraType } from "./types";

Expand All @@ -39,6 +44,7 @@ type ModeActions =
};

export function Cameras({ children }: { children: ReactNode }) {
const defaultEditorCamera = useContext(DefaultCameraContext);
const [activeCamera, setActiveCamera] = useState<
PerspectiveCamera | OrthographicCamera | null
>(null);
Expand All @@ -63,7 +69,7 @@ export function Cameras({ children }: { children: ReactNode }) {

return state;
},
{ edit: "default", editor: "perspective", play: "default" },
{ edit: defaultEditorCamera, editor: "perspective", play: "default" },
);
const activeState = playState === "edit" ? state.edit : state.play;

Expand Down Expand Up @@ -118,9 +124,11 @@ export function Cameras({ children }: { children: ReactNode }) {
case "edit":
case "pause":
activeCamera.layers.enable(EDITOR_LAYER_INDEX);
activeCamera.layers.enable(HIDDEN_LAYER_INDEX);
break;
case "play":
activeCamera.layers.disable(EDITOR_LAYER_INDEX);
activeCamera.layers.disable(HIDDEN_LAYER_INDEX);
break;
}
}, [activeCamera, playState]);
Expand Down
2 changes: 2 additions & 0 deletions packages/renderer/src/features/camera-new/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ export const ActiveCameraContext =
createContext<ActiveCameraContextValue>(null);

export const CameraControlsContext = createContext<CameraControls | null>(null);

export const DefaultCameraContext = createContext<CanvasCamera>("editor");
19 changes: 11 additions & 8 deletions packages/renderer/src/features/scene-loader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ProviderComponent,
} from "@triplex/bridge/client";
import { useEffect, useMemo, useReducer } from "react";
import { DefaultCameraContext } from "../camera-new/context";
import { Canvas } from "../canvas";
import { SceneControls } from "../scene-controls";
import { SceneRenderer } from "../scene-renderer";
Expand Down Expand Up @@ -83,14 +84,16 @@ export function SceneLoader({
<ResetCountContext.Provider value={resetCount}>
<SceneContext.Provider value={sceneContext}>
{scene.meta.root === "react" && (
<ReactDOMSelection filter={{ exportName, path }}>
<SceneRenderer
component={scene.component}
exportName={exportName}
path={path}
props={sceneProps}
/>
</ReactDOMSelection>
<DefaultCameraContext.Provider value="default">
<ReactDOMSelection filter={{ exportName, path }}>
<SceneRenderer
component={scene.component}
exportName={exportName}
path={path}
props={sceneProps}
/>
</ReactDOMSelection>
</DefaultCameraContext.Provider>
)}
{scene.meta.root === "react-three-fiber" && (
<Canvas>
Expand Down

0 comments on commit adb4341

Please sign in to comment.