Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/factory/src/client/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ body {
width: 100%;
}

.canvas-container[data-view='factory'] {
.canvas-container[data-view='factory'],
.canvas-container[data-view='catwalk'] {
aspect-ratio: 2 / 1;
min-width: 800px;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/factory/src/client/components/CatwalkCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DisplayMode, Engine } from 'excalibur';
import React, { useEffect, useRef } from 'react';

import type { CanonicalRunStatus } from '../../shared/types/canonical.js';
import { useContainerResize } from '../hooks/useContainerResize.js';
import { ENGINE_HEIGHT, ENGINE_WIDTH } from '../visualizations/catwalk/constants/dimensions.js';
import { CatwalkScene } from '../visualizations/catwalk/scene/CatwalkScene.js';

Expand All @@ -18,6 +19,8 @@ export function CatwalkCanvas({ status }: CatwalkCanvasProps): React.JSX.Element
const initializedRef = useRef(false);
const startFailedRef = useRef(false);

useContainerResize(canvasRef, engineRef);

useEffect(() => {
if (!canvasRef.current) return;

Expand Down
3 changes: 3 additions & 0 deletions packages/factory/src/client/components/GameCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CanonicalRunStatus } from '../../shared/types/canonical.js';
import type { ArtifactHoverEvent } from '../game/scenes/FactoryScene.js';
import { FactoryScene } from '../game/scenes/FactoryScene.js';
import { loadAllSprites } from '../game/sprites/agent-sprite-loader.js';
import { useContainerResize } from '../hooks/useContainerResize.js';
import { ArtifactTooltip } from './ArtifactTooltip.js';

import './GameCanvas.css';
Expand All @@ -19,6 +20,8 @@ export function GameCanvas({ status }: GameCanvasProps): React.JSX.Element {
const initializedRef = useRef(false);
const [hover, setHover] = useState<ArtifactHoverEvent | null>(null);

useContainerResize(canvasRef, engineRef);

useEffect(() => {
if (!canvasRef.current) return;

Expand Down
127 changes: 127 additions & 0 deletions packages/factory/src/client/hooks/__tests__/useContainerResize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ResizableEngine } from '../useContainerResize.js';
import { useContainerResize } from '../useContainerResize.js';

type ResizeCallback = ResizeObserverCallback;

let capturedCallback: ResizeCallback | undefined;
const mockObserve = vi.fn();
const mockDisconnect = vi.fn();

class MockResizeObserver {
constructor(callback: ResizeCallback) {
capturedCallback = callback;
}
observe = mockObserve;
unobserve = vi.fn();
disconnect = mockDisconnect;
}

describe('useContainerResize', () => {
let originalResizeObserver: typeof globalThis.ResizeObserver;

beforeEach(() => {
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = MockResizeObserver;
capturedCallback = undefined;
mockObserve.mockClear();
mockDisconnect.mockClear();
});

afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
});

it('observes the canvas parent element on mount', () => {
const container = document.createElement('div');
const canvas = document.createElement('canvas');
container.append(canvas);

const canvasRef = { current: canvas };
const engineRef: { current: ResizableEngine | null } = { current: null };

renderHook(() => useContainerResize(canvasRef, engineRef));

expect(mockObserve).toHaveBeenCalledWith(container);
});

it('disconnects the observer on unmount', () => {
const container = document.createElement('div');
const canvas = document.createElement('canvas');
container.append(canvas);

const canvasRef = { current: canvas };
const engineRef: { current: ResizableEngine | null } = { current: null };

const { unmount } = renderHook(() => useContainerResize(canvasRef, engineRef));

unmount();

expect(mockDisconnect).toHaveBeenCalledTimes(1);
});

it('does not observe when canvas ref is null', () => {
const canvasRef = { current: null };
const engineRef: { current: ResizableEngine | null } = { current: null };

renderHook(() => useContainerResize(canvasRef, engineRef));

expect(mockObserve).not.toHaveBeenCalled();
});

it('resets canvas inline styles and invokes resize handler on container resize', () => {
const container = document.createElement('div');
const canvas = document.createElement('canvas');
container.append(canvas);
canvas.style.width = '500px';
canvas.style.height = '250px';

const mockResizeHandler = vi.fn();
const mockEngine: ResizableEngine = {
screen: { _resizeHandler: mockResizeHandler },
};

const canvasRef = { current: canvas };
const engineRef: { current: ResizableEngine | null } = { current: mockEngine };

renderHook(() => useContainerResize(canvasRef, engineRef));

if (capturedCallback === undefined) {
throw new Error('Expected ResizeObserver callback to be captured');
}

// Simulate a container resize
capturedCallback([], new MockResizeObserver(() => {}));

// Canvas inline styles should be reset to 100%
expect(canvas.style.width).toBe('100%');
expect(canvas.style.height).toBe('100%');

// Excalibur's internal resize handler should be invoked
expect(mockResizeHandler).toHaveBeenCalledTimes(1);
});

it('does not throw when engine ref is null during resize', () => {
const container = document.createElement('div');
const canvas = document.createElement('canvas');
container.append(canvas);

const canvasRef = { current: canvas };
const engineRef: { current: ResizableEngine | null } = { current: null };

renderHook(() => useContainerResize(canvasRef, engineRef));

if (capturedCallback === undefined) {
throw new Error('Expected ResizeObserver callback to be captured');
}

const callback = capturedCallback;

// Should not throw when engine is null
expect(() => {
callback([], new MockResizeObserver(() => {}));
}).not.toThrow();
});
});
58 changes: 58 additions & 0 deletions packages/factory/src/client/hooks/useContainerResize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect } from 'react';

/** Minimal engine shape required by the resize hook -- the full Excalibur Engine satisfies this. */
export interface ResizableEngine {
screen: unknown;
}

interface ScreenWithResizeHandler {
_resizeHandler: () => void;
}

/** Returns true if the engine screen exposes Excalibur's internal resize handler. */
function hasResizeHandler(screen: unknown): screen is ScreenWithResizeHandler {
return typeof screen === 'object' && screen !== null && '_resizeHandler' in screen;
}

/**
* Observes the canvas container for size changes and triggers Excalibur's screen resize.
*
* Excalibur's DisplayMode.FitContainer recalculates canvas scaling when the container
* resizes, but fixed pixel dimensions set on the canvas by previous calculations can
* prevent the container from growing. This hook adds a ResizeObserver on the container
* that resets the canvas to fluid CSS sizing before invoking Excalibur's resize handler,
* allowing the container to expand and the engine to recalculate scaling correctly.
*/
export function useContainerResize(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
engineRef: React.RefObject<ResizableEngine | null>,
): void {
useEffect(() => {
const canvas = canvasRef.current;
const container = canvas?.parentElement;
if (!container) return;

const observer = new ResizeObserver(() => {
const engine = engineRef.current;
if (!engine) return;

// Reset canvas inline dimensions so the container can grow to its
// CSS-defined size. Without this, Excalibur's previously set pixel
// values on the canvas prevent the container from expanding.
canvas.style.width = '100%';
canvas.style.height = '100%';

// Invoke Excalibur's internal resize handler, which reads the
// container's current dimensions and recalculates viewport scaling.
if (hasResizeHandler(engine.screen)) {
engine.screen._resizeHandler();
}
});

observer.observe(container);

return () => {
observer.disconnect();
};
}, [canvasRef, engineRef]);
}
8 changes: 8 additions & 0 deletions packages/factory/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
import '@testing-library/jest-dom/vitest';

// jsdom does not provide ResizeObserver. Assign a minimal stub so that
// components using useContainerResize can be rendered in tests.
globalThis.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};