Skip to content
Draft
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
19 changes: 19 additions & 0 deletions packages/joint-react/src/components/paper/paper.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { CellVisibility } from '../../presets/cell-visibility';
import type { CellInteractivity } from '../../presets/cell-interactivity';
import type { LinkRouting } from '../../presets/link-routing';
import type { PaperEventHandlers } from '../../presets/paper-events';
import type { PinchZoomBounds } from '../../hooks/use-pinch-zoom';

/**
* Viewport transform accepted by the `<Paper>` `transform` prop: either a CSS
Expand Down Expand Up @@ -474,6 +475,24 @@ export interface PaperProps extends PaperSupportedOptions, PropsWithChildren, Pa
*/
readonly transform?: PaperTransform;

/**
* Built-in pinch-to-zoom: two-finger touch pinches and touchpad
* (`Ctrl`/`Cmd`+wheel) pinches zoom the canvas around the gesture point,
* and two-finger touch pans move it. Pass `{ min, max }` to tune the zoom
* bounds, or `false` to turn the behavior off (e.g. when the viewport is
* driven through the `transform` prop).
*
* Subscribing your own `onPaperPinch` / `onPaperPan` handler takes
* precedence — the built-in behavior yields automatically, no need to also
* pass `false`.
* @default true
* @example
* ```tsx
* <Paper zoomOnPinch={{ min: 0.5, max: 2 }} />
* ```
*/
readonly zoomOnPinch?: boolean | PinchZoomBounds;

/**
* Maximum pointer travel (in px) still treated as a click rather than a drag.
* Moving farther than this between press and release suppresses the
Expand Down
146 changes: 146 additions & 0 deletions packages/joint-react/src/hooks/__tests__/use-pinch-zoom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/* eslint-disable react-perf/jsx-no-new-function-as-prop */
import { render, act, waitFor } from '@testing-library/react';
import type { dia } from '@joint/core';
import { GraphProvider } from '../../components';
import { Paper } from '../../components/paper/paper';
import type { PaperProps } from '../../components/paper/paper.types';
import type { CellRecord } from '../../types/cell.types';

const EMPTY_CELLS: readonly CellRecord[] = [];

function touchPanEvent(): dia.Event {
return { type: 'touchmove' } as unknown as dia.Event;
}

function wheelPanEvent(): dia.Event {
return { type: 'wheel' } as unknown as dia.Event;
}

// jsdom's mocked SVG matrix degenerates real transform math (all-zero CTM), so
// the paper's transform API is stubbed and the assertions target what the hook
// owns: clamping, yielding, and event filtering.
function stubPaperTransforms(paper: dia.Paper) {
const state = { scale: 1, tx: 0, ty: 0 };
// The getter/setter overloads defeat direct mock typing — cast the whole
// implementation to the method type instead.
jest
.spyOn(paper, 'scale')
.mockImplementation(
(() => ({ sx: state.scale, sy: state.scale })) as unknown as dia.Paper['scale']
);
const scaleUniformAtPoint = jest
.spyOn(paper, 'scaleUniformAtPoint')
.mockImplementation((nextScale: number) => {
state.scale = nextScale;
return paper;
});
const translate = jest.spyOn(paper, 'translate').mockImplementation(
((tx?: number, ty?: number) => {
if (tx === undefined || ty === undefined) return { tx: state.tx, ty: state.ty };
state.tx = tx;
state.ty = ty;
return paper;
}) as unknown as dia.Paper['translate']
);
return { state, scaleUniformAtPoint, translate };
}

async function renderPaper(props: Partial<PaperProps> = {}) {
const paperRef: { current: dia.Paper | null } = { current: null };
render(
<GraphProvider initialCells={EMPTY_CELLS}>
<Paper
ref={(paper: dia.Paper | null) => {
paperRef.current = paper;
}}
{...props}
/>
</GraphProvider>
);
await waitFor(() => expect(paperRef.current).toBeTruthy());
const paper = paperRef.current;
if (!paper) throw new Error('Paper was not created.');
return { paper, ...stubPaperTransforms(paper) };
}

describe('usePinchZoom (Paper zoomOnPinch)', () => {
it('applies pinch samples as a clamped uniform scale by default', async () => {
const { paper, state, scaleUniformAtPoint } = await renderPaper();

act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 20, 2));
expect(scaleUniformAtPoint).toHaveBeenCalledWith(2, { x: 10, y: 20 });
expect(state.scale).toBe(2);

act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 20, 2));
expect(state.scale).toBe(4);

// 4 × 2 = 8 exceeds the default upper bound of 5.
act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 20, 2));
expect(state.scale).toBe(5);
});

it('respects custom bounds', async () => {
const { paper, state } = await renderPaper({ zoomOnPinch: { min: 0.5, max: 2 } });

act(() => paper.trigger('paper:pinch', touchPanEvent(), 0, 0, 4));
expect(state.scale).toBe(2);

act(() => paper.trigger('paper:pinch', touchPanEvent(), 0, 0, 0.1));
expect(state.scale).toBe(0.5);
});

it('is disabled with zoomOnPinch={false}', async () => {
const { paper, scaleUniformAtPoint } = await renderPaper({ zoomOnPinch: false });

act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2));
expect(scaleUniformAtPoint).not.toHaveBeenCalled();
});

it('yields to an external paper:pinch subscriber', async () => {
const { paper, state, scaleUniformAtPoint } = await renderPaper();
const external = jest.fn();
paper.on('paper:pinch', external);

act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2));
expect(external).toHaveBeenCalledTimes(1);
expect(scaleUniformAtPoint).not.toHaveBeenCalled(); // the external handler owns zooming

// Once the external subscriber is gone, the built-in takes over again.
paper.off('paper:pinch', external);
act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2));
expect(state.scale).toBe(2);
});

it('yields to the onPaperPinch prop', async () => {
const onPaperPinch = jest.fn();
const { paper, scaleUniformAtPoint } = await renderPaper({ onPaperPinch });

act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2));
expect(onPaperPinch).toHaveBeenCalledTimes(1);
expect(scaleUniformAtPoint).not.toHaveBeenCalled();
});

it('applies touch-sourced pan samples as a translation', async () => {
const { paper, state } = await renderPaper();

act(() => paper.trigger('paper:pan', touchPanEvent(), 10, 20));
expect(state).toMatchObject({ tx: -10, ty: -20 });
});

it('ignores wheel-sourced pan events', async () => {
const { paper, translate } = await renderPaper();

act(() => paper.trigger('paper:pan', wheelPanEvent(), 10, 20));
expect(translate).not.toHaveBeenCalled();
});

it('yields to an external paper:pan subscriber', async () => {
const { paper, translate } = await renderPaper();
const external = jest.fn();
paper.on('paper:pan', external);

act(() => paper.trigger('paper:pan', touchPanEvent(), 10, 20));
expect(external).toHaveBeenCalledTimes(1);
expect(translate).not.toHaveBeenCalled();
});
});
3 changes: 3 additions & 0 deletions packages/joint-react/src/hooks/use-create-portal-paper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { useAreElementsMeasured } from './use-are-elements-measured';
import { LINK_MODEL_TYPE } from '../mvc/link-model';
import { subscribeToPaperEvents } from './use-on-paper-events';
import { useOnEvents } from './use-on-events';
import { usePinchZoom } from './use-pinch-zoom';
import type { CellId } from '../types/cell.types';
import { extractEventsFromPaperProps } from '../presets/paper-events';

Expand Down Expand Up @@ -193,6 +194,7 @@ export function useCreatePortalPaper(
transform,
portalSelector,
linkRouting,
zoomOnPinch,
options: escapeHatchOptions,
// These are React host props and must not be forwarded to dia.Paper options.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -310,6 +312,7 @@ export function useCreatePortalPaper(

const eventHandlers = useMemo(() => extractEventsFromPaperProps(paperOptions), [paperOptions]);
useOnEvents(paperStore, eventHandlers, subscribeToPaperEvents);
usePinchZoom(paperStore, zoomOnPinch);

useLayoutEffect(() => {
const hostElementForCreation = nodeRef?.current;
Expand Down
88 changes: 88 additions & 0 deletions packages/joint-react/src/hooks/use-pinch-zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { mvc, type dia } from '@joint/core';
import { useEffect } from 'react';
import type { PaperStore } from '../store/paper-store';

/**
* Zoom limits for the built-in pinch-zoom behavior of `<Paper>`.
* @group Types
*/
export interface PinchZoomBounds {
/**
* Lower zoom bound.
* @default 0.2
*/
readonly min?: number;
/**
* Upper zoom bound.
* @default 5
*/
readonly max?: number;
}

const DEFAULT_MIN_ZOOM = 0.2;
const DEFAULT_MAX_ZOOM = 5;

/**
* Count subscribers of a paper event — the same internal `_events`
* introspection joint-core uses to gate its ctrl+wheel pinch handling.
* @param paper - The paper whose subscriptions are inspected.
* @param eventName - Paper event name.
* @returns Number of handlers currently subscribed.
*/
function countSubscribers(paper: dia.Paper, eventName: string): number {
const events = (paper as unknown as { _events?: Record<string, readonly unknown[]> })._events;
return events?.[eventName]?.length ?? 0;
}

function isTouchSourced(event: dia.Event): boolean {
const type = (event as { type?: string }).type ?? '';
return type.startsWith('touch');
}

/**
* Built-in pinch-zoom behavior for `<Paper>`: applies `paper:pinch` samples as
* a clamped `scaleUniformAtPoint` around the gesture point, and touch-sourced
* `paper:pan` samples as a translation — so pinch-to-zoom and two-finger pan
* work out of the box on a touchscreen and a touchpad (ctrl+wheel).
*
* Self-yielding: whenever any other subscriber listens to the same event (an
* `onPaperPinch` / `onPaperPan` prop, a scroller's interactions), that
* subscriber owns the gesture and the built-in does nothing. Checked per
* event, so handlers subscribed at any time are honored.
* @param paperStore - Store owning the paper, or nullish before creation.
* @param zoomOnPinch - The `zoomOnPinch` prop: `false` disables, an object tunes the bounds.
* @internal
*/
export function usePinchZoom(
paperStore: PaperStore | null | undefined,
zoomOnPinch: boolean | PinchZoomBounds | undefined
): void {
const isEnabled = zoomOnPinch !== false;
const bounds = typeof zoomOnPinch === 'object' ? zoomOnPinch : undefined;
const minZoom = bounds?.min ?? DEFAULT_MIN_ZOOM;
const maxZoom = bounds?.max ?? DEFAULT_MAX_ZOOM;
const paper = paperStore?.paper;

useEffect(() => {
if (!paper || !isEnabled) return;
const onPinch = (_event: dia.Event, x: number, y: number, scale: number) => {
if (countSubscribers(paper, 'paper:pinch') > 1) return;
const currentScale = paper.scale().sx;
const nextScale = Math.min(maxZoom, Math.max(minZoom, currentScale * scale));
if (nextScale === currentScale) return;
paper.scaleUniformAtPoint(nextScale, { x, y });
};
const onPan = (event: dia.Event, deltaX: number, deltaY: number) => {
// Touch gestures only: wheel emits `paper:pan` for every plain scroll,
// and out-of-the-box wheel panning is not part of this behavior.
if (!isTouchSourced(event)) return;
if (countSubscribers(paper, 'paper:pan') > 1) return;
const { tx, ty } = paper.translate();
paper.translate(tx - deltaX, ty - deltaY);
};
const controller = new mvc.Listener();
controller.listenTo(paper, 'paper:pinch', onPinch);
controller.listenTo(paper, 'paper:pan', onPan);
return () => controller.stopListening();
}, [paper, isEnabled, minZoom, maxZoom]);
}
2 changes: 2 additions & 0 deletions packages/joint-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export type {
DefaultLinkParams,
} from './components/paper/paper.types';
/** @group Types */
export type { PinchZoomBounds } from './hooks/use-pinch-zoom';
/** @group Types */
export type { PortalHostCell, PortalSelector, PortalSelectorParams } from './mvc/paper.types';
/** @group Types */
export type {
Expand Down
1 change: 1 addition & 0 deletions packages/joint-react/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export { assignOptions, pickValues, makeOptions } from './utils/object-utilities
export { resolvePaper, resolvePaperId, isPaperTarget } from './utils/resolve-paper-target';
export { isRecord } from './utils/is';
export { wheelGuard, SCROLLABLE_ATTRIBUTE } from './utils/wheel-guard';
export { isMultiTouchEvent } from './utils/touch-gestures';

// Constants
export { ELEMENT_MODEL_TYPE, PORTAL_SELECTOR } from './mvc/element-model';
Expand Down
Loading