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
1 change: 1 addition & 0 deletions ESLint/lib/kernelBoundary.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const kernelSourcePaths = Object.freeze([
'Source/DataTables/DataTableFilterMeta.ts',
'Source/DataTables/paginatorRange.ts',
'Source/DataTables/selectionKeys.ts',
'Source/ClassNames/classNames.ts',
'Source/CommandDialog/applyBeforeExecute.ts',
'Source/CommandForm/commandFormMarkers.ts',
'Source/CommandForm/fields/chipValues.ts',
Expand Down
110 changes: 109 additions & 1 deletion Source/Canvas/Canvas.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

import type { Meta, StoryObj } from '@storybook/react';
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { Canvas } from './Canvas';
import { expect, waitFor, within } from 'storybook/test';
import { Canvas, type CanvasHandle } from './Canvas';
import { CanvasItem } from './CanvasItem';

const meta: Meta<typeof Canvas> = {
Expand Down Expand Up @@ -180,3 +181,110 @@ export const WithControlsAndMinimap: Story = {
return <WithControlsAndMinimapDemo />;
},
};

/** The camera reported by `onTransformChange`, written where a test can read it. */
const CameraReadout = ({ zoom, pan }: { zoom: number; pan: { x: number; y: number } }) => (
<output
data-testid='camera'
data-zoom={zoom.toFixed(4)}
data-pan-x={pan.x.toFixed(2)}
data-pan-y={pan.y.toFixed(2)}
style={{ position: 'absolute', top: 8, right: 8, zIndex: 10, font: '12px monospace', background: 'white', padding: 4 }}
>
zoom {zoom.toFixed(2)} · pan {pan.x.toFixed(0)}, {pan.y.toFixed(0)}
</output>
);

/**
* The camera driven through every input path: wheel pan, Ctrl+wheel zoom toward the pointer, a
* background drag, the zoom controls, and the imperative handle. The readout shows what
* `onTransformChange` reports.
*/
export const CameraInteractions: Story = {
render: () => {
const CameraInteractionsDemo = () => {
const [camera, setCamera] = useState({ zoom: 1, pan: { x: 0, y: 0 } });
const handleRef = useRef<CanvasHandle | null>(null);
return (
<div style={{ width: 800, height: 600, position: 'relative' }}>
<Canvas
showControls
onTransformChange={(zoom, pan) => setCamera({ zoom, pan: { ...pan } })}
onHandleReady={(handle) => { handleRef.current = handle; }}
style={{ width: 800, height: 600 }}
>
<CanvasItem id='sample' x={100} y={50}>
<div style={{ width: 120, height: 60, background: BOX_COLORS[0], color: 'white' }}>Sample</div>
</CanvasItem>
</Canvas>
<CameraReadout zoom={camera.zoom} pan={camera.pan} />
<button type='button' style={{ position: 'absolute', top: 40, right: 8, zIndex: 10 }} onClick={() => handleRef.current?.smoothPanToWorld(0, 0, 1)}>
Center origin
</button>
<output data-testid='bounds' style={{ position: 'absolute', top: 72, right: 8, zIndex: 10 }}>
<button type='button' onClick={(event) => {
event.currentTarget.parentElement!.dataset.bounds = JSON.stringify(handleRef.current?.getItemBounds() ?? []);
}}>
Read bounds
</button>
</output>
</div>
);
};
return <CameraInteractionsDemo />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const surface = canvasElement.querySelector<HTMLElement>('.canvas-surface')!;
const camera = () => canvas.getByTestId('camera');
const expectCamera = async (zoom: number, panX: number, panY: number) => {
await waitFor(() => {
expect(Number(camera().dataset.zoom)).toBeCloseTo(zoom, 3);
expect(Number(camera().dataset.panX)).toBeCloseTo(panX, 1);
expect(Number(camera().dataset.panY)).toBeCloseTo(panY, 1);
});
};
await waitFor(() => expect(surface.querySelector('canvas')).not.toBeNull());
const rect = surface.getBoundingClientRect();

// A plain wheel pans by the wheel delta.
surface.dispatchEvent(new WheelEvent('wheel', { deltaX: 30, deltaY: 40, clientX: rect.left + 10, clientY: rect.top + 10, bubbles: true, cancelable: true }));
await expectCamera(1, -30, -40);

// Ctrl+wheel zooms toward the pointer, holding the world point under it still.
const focusX = 200;
const focusY = 100;
surface.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, ctrlKey: true, clientX: rect.left + focusX, clientY: rect.top + focusY, bubbles: true, cancelable: true }));
const zoomed = Math.exp(100 * 0.008);
await expectCamera(zoomed, focusX - (focusX + 30) * zoomed, focusY - (focusY + 40) * zoomed);

// The zoom controls: reset returns to 100% and keeps the pan; zoom in scales around the center.
const panX = Number(camera().dataset.panX);
const panY = Number(camera().dataset.panY);
canvas.getByRole('button', { name: 'Reset Zoom' }).click();
await expectCamera(1, panX, panY);
canvas.getByRole('button', { name: 'Zoom In' }).click();
await expectCamera(1.2, 400 - (400 - panX) * 1.2, 300 - (300 - panY) * 1.2);
canvas.getByRole('button', { name: 'Zoom Out' }).click();
await expectCamera(1, panX, panY);

// A mouse drag on the empty background pans by the drag distance.
const pointer = { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1, bubbles: true, cancelable: true };
const background = surface.querySelector('canvas')!;
background.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, clientX: rect.left + 500, clientY: rect.top + 400 }));
surface.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: rect.left + 520, clientY: rect.top + 430 }));
surface.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientX: rect.left + 520, clientY: rect.top + 430 }));
await expectCamera(1, panX + 20, panY + 30);

// The imperative handle centers a world point.
canvas.getByRole('button', { name: 'Center origin' }).click();
await expectCamera(1, 400, 300);

// The handle reports the measured bounds of registered items.
canvas.getByRole('button', { name: 'Read bounds' }).click();
await waitFor(() => {
const bounds = JSON.parse(canvas.getByTestId('bounds').dataset.bounds ?? '[]') as Array<{ x: number; y: number; width: number; height: number }>;
expect(bounds).toEqual([{ x: 100, y: 50, width: 120, height: 60 }]);
});
},
};
Loading
Loading